diff --git a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts index 89abd66df..8799aa4ad 100644 --- a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts +++ b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts @@ -13,13 +13,13 @@ function promptHydrateRT({ preHydrateCallbacks, hydrateRTDisplaySet, }: withAppTypes) { - const { uiViewportDialogService } = servicesManager.services; + const { uiViewportDialogService, customizationService } = servicesManager.services; const extensionManager = servicesManager._extensionManager; const appConfig = extensionManager._appConfig; return new Promise(async function (resolve, reject) { const promptResult = appConfig?.disableConfirmationPrompts ? RESPONSE.HYDRATE_SEG - : await _askHydrate(uiViewportDialogService, viewportId); + : await _askHydrate(uiViewportDialogService, customizationService, viewportId); if (promptResult === RESPONSE.HYDRATE_SEG) { preHydrateCallbacks?.forEach(callback => { @@ -37,9 +37,13 @@ function promptHydrateRT({ }); } -function _askHydrate(uiViewportDialogService: AppTypes.UIViewportDialogService, viewportId) { +function _askHydrate( + uiViewportDialogService: AppTypes.UIViewportDialogService, + customizationService: AppTypes.CustomizationService, + viewportId +) { return new Promise(function (resolve, reject) { - const message = 'Do you want to open this Segmentation?'; + const message = customizationService.getCustomization('viewportNotification.hydrateRTMessage'); const actions = [ { id: 'no-hydrate', diff --git a/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts index 9d3a8c8b6..b6aa263d3 100644 --- a/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts +++ b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts @@ -13,14 +13,14 @@ function promptHydrateSEG({ preHydrateCallbacks, hydrateCallback, }: withAppTypes) { - const { uiViewportDialogService } = servicesManager.services; + const { uiViewportDialogService, customizationService } = servicesManager.services; const extensionManager = servicesManager._extensionManager; const appConfig = extensionManager._appConfig; return new Promise(async function (resolve, reject) { const promptResult = appConfig?.disableConfirmationPrompts ? RESPONSE.HYDRATE_SEG - : await _askHydrate(uiViewportDialogService, viewportId); + : await _askHydrate(uiViewportDialogService, customizationService, viewportId); if (promptResult === RESPONSE.HYDRATE_SEG) { preHydrateCallbacks?.forEach(callback => { @@ -39,9 +39,13 @@ function promptHydrateSEG({ }); } -function _askHydrate(uiViewportDialogService, viewportId) { +function _askHydrate( + uiViewportDialogService: AppTypes.UIViewportDialogService, + customizationService: AppTypes.CustomizationService, + viewportId +) { return new Promise(function (resolve, reject) { - const message = 'Do you want to open this Segmentation?'; + const message = customizationService.getCustomization('viewportNotification.hydrateSEGMessage'); const actions = [ { id: 'no-hydrate', diff --git a/extensions/cornerstone/src/components/AccordionGroup/AccordionGroup.tsx b/extensions/cornerstone/src/components/AccordionGroup/AccordionGroup.tsx index 2091089a8..337614a29 100644 --- a/extensions/cornerstone/src/components/AccordionGroup/AccordionGroup.tsx +++ b/extensions/cornerstone/src/components/AccordionGroup/AccordionGroup.tsx @@ -179,7 +179,10 @@ function Trigger(props) { >
{React.cloneElement(children, childProps)} - +
); diff --git a/extensions/cornerstone/src/components/MeasurementsOrAdditionalFindings.tsx b/extensions/cornerstone/src/components/MeasurementsOrAdditionalFindings.tsx index a420dc0d3..f1fff2306 100644 --- a/extensions/cornerstone/src/components/MeasurementsOrAdditionalFindings.tsx +++ b/extensions/cornerstone/src/components/MeasurementsOrAdditionalFindings.tsx @@ -54,7 +54,7 @@ export const groupByNamedSets = (items, grouping) => { }; export function MeasurementsOrAdditionalFindings(props): React.ReactNode { - const { items, children, grouping = {}, customHeader, group } = props; + const { items, children, grouping = {}, customHeader, group, actions } = props; return ( diff --git a/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx b/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx index 08f151234..c3cdcbdda 100644 --- a/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx +++ b/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx @@ -53,6 +53,10 @@ export function StudyMeasurementsActions({ items, StudyInstanceUID, measurementF className="pl-0.5" onClick={e => { e.stopPropagation(); + if (actions?.onDelete) { + actions.onDelete(); + return; + } commandsManager.runCommand('clearMeasurements', { measurementFilter, }); diff --git a/extensions/cornerstone/src/initMeasurementService.ts b/extensions/cornerstone/src/initMeasurementService.ts index 156f1e705..f1567e5f8 100644 --- a/extensions/cornerstone/src/initMeasurementService.ts +++ b/extensions/cornerstone/src/initMeasurementService.ts @@ -7,7 +7,11 @@ import { toolNames } from './initCornerstoneTools'; import { onCompletedCalibrationLine } from './tools/CalibrationLineTool'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; -import { triggerAnnotationRenderForViewportIds } from '@cornerstonejs/tools/utilities'; +import { + setAnnotationLabel, + triggerAnnotationRenderForViewportIds, +} from '@cornerstonejs/tools/utilities'; +import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums; const { removeAnnotation } = annotation.state; @@ -190,6 +194,7 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana displaySetService, cornerstoneViewportService, customizationService, + viewportGridService, } = servicesManager.services; const csTools3DVer1MeasurementSource = initMeasurementService( measurementService, @@ -197,7 +202,11 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana cornerstoneViewportService, customizationService ); - connectMeasurementServiceToTools(measurementService, cornerstoneViewportService); + connectMeasurementServiceToTools( + measurementService, + cornerstoneViewportService, + viewportGridService + ); const { annotationToMeasurement, remove } = csTools3DVer1MeasurementSource; // @@ -323,7 +332,11 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana return csTools3DVer1MeasurementSource; }; -const connectMeasurementServiceToTools = (measurementService, cornerstoneViewportService) => { +const connectMeasurementServiceToTools = ( + measurementService, + cornerstoneViewportService, + viewportGridService +) => { const { MEASUREMENT_REMOVED, MEASUREMENTS_CLEARED, MEASUREMENT_UPDATED, RAW_MEASUREMENT_ADDED } = measurementService.EVENTS; @@ -366,7 +379,8 @@ const connectMeasurementServiceToTools = (measurementService, cornerstoneViewpor } if (data.label !== label) { - data.label = label; + const element = getActiveViewportEnabledElement(viewportGridService)?.viewport.element; + setAnnotationLabel(sourceAnnotation, element, label); } if (metadata.toolName === 'ArrowAnnotate') { diff --git a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx index a885f6f8c..c8add4e50 100644 --- a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx @@ -34,8 +34,9 @@ function PanelStudyBrowser({ dataSource, customMapDisplaySets, onClickUntrack, + onDoubleClickThumbnailHandlerCallBack, }) { - const { servicesManager, commandsManager } = useSystem(); + const { servicesManager, commandsManager, extensionManager } = useSystem(); const { displaySetService, customizationService } = servicesManager.services; const navigate = useNavigate(); const studyMode = customizationService.getCustomization('studyBrowser.studyMode') || 'all'; @@ -93,6 +94,7 @@ function PanelStudyBrowser({ commandsManager, servicesManager, isHangingProtocolLayout, + appConfig: extensionManager._appConfig, }; const handlers = customHandler?.callbacks.map(callback => callback(setupArgs)); @@ -100,6 +102,7 @@ function PanelStudyBrowser({ for (const handler of handlers) { await handler(displaySetInstanceUID); } + onDoubleClickThumbnailHandlerCallBack?.(displaySetInstanceUID); }, [ activeViewportId, diff --git a/extensions/default/src/customizations/notificationCustomization.ts b/extensions/default/src/customizations/notificationCustomization.ts index 6bff2ded7..360e1d267 100644 --- a/extensions/default/src/customizations/notificationCustomization.ts +++ b/extensions/default/src/customizations/notificationCustomization.ts @@ -1,5 +1,29 @@ +import i18n from '@ohif/i18n'; import { Notification } from '@ohif/ui'; +const beginTrackingMessage = i18n.t('MeasurementTable:Track measurements for this series?'); +const trackNewSeriesMessage = i18n.t('Do you want to add this measurement to the existing report?'); +const discardSeriesMessage = i18n.t( + 'You have existing tracked measurements. What would you like to do with your existing tracked measurements?' +); +const trackNewStudyMessage = i18n.t('MeasurementTable:Track measurements for this series?'); +const discardStudyMessage = i18n.t( + 'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?' +); +const hydrateSRMessage = i18n.t('Do you want to continue tracking measurements for this study?'); +const hydrateRTMessage = i18n.t('Do you want to open this Segmentation?'); +const hydrateSEGMessage = i18n.t('Do you want to open this Segmentation?'); +const discardDirtyMessage = i18n.t('There are unsaved measurements. Do you want to save it?'); + export default { 'ui.notificationComponent': Notification, + 'viewportNotification.beginTrackingMessage': beginTrackingMessage, + 'viewportNotification.trackNewSeriesMessage': trackNewSeriesMessage, + 'viewportNotification.discardSeriesMessage': discardSeriesMessage, + 'viewportNotification.trackNewStudyMessage': trackNewStudyMessage, + 'viewportNotification.discardStudyMessage': discardStudyMessage, + 'viewportNotification.hydrateSRMessage': hydrateSRMessage, + 'viewportNotification.hydrateRTMessage': hydrateRTMessage, + 'viewportNotification.hydrateSEGMessage': hydrateSEGMessage, + 'viewportNotification.discardDirtyMessage': discardDirtyMessage, }; diff --git a/extensions/default/src/utils/callInputDialog.tsx b/extensions/default/src/utils/callInputDialog.tsx index 089a911bd..65c2e41ac 100644 --- a/extensions/default/src/utils/callInputDialog.tsx +++ b/extensions/default/src/utils/callInputDialog.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { setAnnotationLabel } from '@cornerstonejs/tools/utilities'; +import { annotation } from '@cornerstonejs/tools'; import { LabellingFlow } from '@ohif/ui'; import { InputDialog } from '@ohif/ui-next'; @@ -89,6 +91,7 @@ export async function callInputDialogAutoComplete({ uiDialogService, labelConfig, renderContent = LabellingFlow, + element, }) { const exclusive = labelConfig ? labelConfig.exclusive : false; const dropDownItems = labelConfig ? labelConfig.items : []; @@ -97,7 +100,8 @@ export async function callInputDialogAutoComplete({ const labellingDoneCallback = newValue => { uiDialogService.hide('select-annotation'); if (measurement && typeof newValue === 'string') { - measurement.label = newValue; + const sourceAnnotation = annotation.state.getAnnotation(measurement.uid); + setAnnotationLabel(sourceAnnotation, element, newValue); } resolve(newValue); }; diff --git a/extensions/default/src/utils/promptSaveReport.tsx b/extensions/default/src/utils/promptSaveReport.tsx index 874d79986..22b2905fc 100644 --- a/extensions/default/src/utils/promptSaveReport.tsx +++ b/extensions/default/src/utils/promptSaveReport.tsx @@ -18,6 +18,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave; const StudyInstanceUID = evt?.data?.StudyInstanceUID || ctx.trackedStudy; const SeriesInstanceUID = evt?.data?.SeriesInstanceUID; + const { displaySetInstanceUID } = evt.data ?? evt; const { trackedSeries, @@ -76,6 +77,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan SeriesInstanceUID, viewportId, isBackupSave, + displaySetInstanceUID, }; } catch (error) { console.warn('Unable to save report', error); diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx index 73696fdf2..6290d3cdd 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx @@ -3,14 +3,19 @@ import PropTypes from 'prop-types'; import { Machine } from 'xstate'; import { useMachine } from '@xstate/react'; import { useViewportGrid } from '@ohif/ui-next'; -import { promptLabelAnnotation, promptSaveReport } from '@ohif/extension-default'; import { machineConfiguration, defaultOptions, RESPONSE } from './measurementTrackingMachine'; -import promptBeginTracking from './promptBeginTracking'; -import promptTrackNewSeries from './promptTrackNewSeries'; -import promptTrackNewStudy from './promptTrackNewStudy'; -import promptHydrateStructuredReport from './promptHydrateStructuredReport'; +import { measurementTrackingMode } from './promptBeginTracking'; import hydrateStructuredReport from './hydrateStructuredReport'; import { useAppConfig } from '@state'; +import { + promptBeginTrackingWrapper, + promptHydrateStructuredReportWrapper, + promptTrackNewSeriesWrapper, + promptTrackNewStudyWrapper, + promptLabelAnnotationWrapper, + promptSaveReportWrapper, + promptHasDirtyAnnotationsWrapper, +} from './promptWrapperFunctions'; const TrackedMeasurementsContext = React.createContext(); TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext'; @@ -35,7 +40,8 @@ function TrackedMeasurementsContextProvider( const machineOptions = Object.assign({}, defaultOptions); machineOptions.actions = Object.assign({}, machineOptions.actions, { jumpToFirstMeasurementInActiveViewport: (ctx, evt) => { - const { trackedStudy, trackedSeries, activeViewportId } = ctx; + const { trackedStudy, trackedSeries } = ctx; + const { viewportId: activeViewportId } = evt.data; const measurements = measurementService.getMeasurements(); const trackedMeasurements = measurements.filter( m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID) @@ -82,7 +88,8 @@ function TrackedMeasurementsContextProvider( }, jumpToSameImageInActiveViewport: (ctx, evt) => { - const { trackedStudy, trackedSeries, activeViewportId } = ctx; + const { trackedStudy, trackedSeries } = ctx; + const { viewportId: activeViewportId } = evt.data; const measurements = measurementService.getMeasurements(); const trackedMeasurements = measurements.filter( m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID) @@ -143,31 +150,64 @@ function TrackedMeasurementsContextProvider( for (let i = 0; i < measurementIds.length; i++) { measurementService.remove(measurementIds[i]); } + measurementService.setIsMeasurementDeletedIndividually(false); + }, + clearDisplaySetHydratedState: (ctx, evt) => { + const { displaySetInstanceUID } = evt.data ?? evt; + + const displaysets = displaySetService.getActiveDisplaySets(); + displaysets?.forEach(displayset => { + if ( + displayset.Modality === 'SR' && + displayset.displaySetInstanceUID !== displaySetInstanceUID && + displayset.isHydrated + ) { + displayset.isHydrated = false; + displayset.isLoaded = false; + } + }); + }, + updatedViewports: (ctx, evt) => { + const { hangingProtocolService } = servicesManager.services; + const { displaySetInstanceUID, viewportId } = evt.data ?? evt; + + const updatedViewports = hangingProtocolService.getViewportsRequireUpdate( + viewportId, + displaySetInstanceUID + ); + + viewportGridService.setDisplaySetsForViewports(updatedViewports); }, }); machineOptions.services = Object.assign({}, machineOptions.services, { - promptBeginTracking: promptBeginTracking.bind(null, { + promptBeginTracking: promptBeginTrackingWrapper.bind(null, { servicesManager, extensionManager, appConfig, }), - promptTrackNewSeries: promptTrackNewSeries.bind(null, { + promptTrackNewSeries: promptTrackNewSeriesWrapper.bind(null, { servicesManager, extensionManager, appConfig, }), - promptTrackNewStudy: promptTrackNewStudy.bind(null, { + promptTrackNewStudy: promptTrackNewStudyWrapper.bind(null, { servicesManager, extensionManager, appConfig, }), - promptSaveReport: promptSaveReport.bind(null, { + promptSaveReport: promptSaveReportWrapper.bind(null, { servicesManager, commandsManager, extensionManager, appConfig, }), - promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, { + promptHydrateStructuredReport: promptHydrateStructuredReportWrapper.bind(null, { + servicesManager, + extensionManager, + commandsManager, + appConfig, + }), + promptHasDirtyAnnotations: promptHasDirtyAnnotationsWrapper.bind(null, { servicesManager, extensionManager, commandsManager, @@ -179,7 +219,7 @@ function TrackedMeasurementsContextProvider( commandsManager, appConfig, }), - promptLabelAnnotation: promptLabelAnnotation.bind(null, { + promptLabelAnnotation: promptLabelAnnotationWrapper.bind(null, { servicesManager, extensionManager, commandsManager, @@ -194,6 +234,25 @@ function TrackedMeasurementsContextProvider( const labelConfig = customizationService.getCustomization('measurementLabels'); return evt.data && evt.data.userResponse === RESPONSE.NO_NEVER && labelConfig?.labelOnMeasure; }, + isSimplifiedConfig: (ctx, evt, condMeta) => { + return appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED; + }, + simplifiedAndLoadSR: (ctx, evt, condMeta) => { + return ( + appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED && + evt.data.isBackupSave === false + ); + }, + hasDirtyAndSimplified: (ctx, evt, condMeta) => { + const measurements = measurementService.getMeasurements(); + const hasDirtyMeasurements = + measurements.some(measurement => measurement.isDirty) || + (measurements.length && measurementService.getIsMeasurementDeletedIndividually()); + return ( + appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED && + hasDirtyMeasurements + ); + }, }); // TODO: IMPROVE @@ -266,7 +325,8 @@ function TrackedMeasurementsContextProvider( // load function added by our sopClassHandler module if ( displaySet.SOPClassHandlerId === SR_SOPCLASSHANDLERID && - displaySet.isRehydratable === true + displaySet.isRehydratable === true && + !displaySet.isHydrated ) { const params = { displaySetInstanceUID: displaySet.displaySetInstanceUID, diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js index 9a48439ce..b84040c73 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js @@ -122,13 +122,27 @@ const machineConfiguration = { UNTRACK_SERIES: [ { target: 'tracking', - actions: ['removeTrackedSeries', 'setIsDirty'], + actions: ['removeTrackedSeries', 'setIsDirty', 'clearDisplaySetHydratedState'], cond: 'hasRemainingTrackedSeries', }, { target: 'idle', }, ], + UNTRACK_ALL: [ + { + target: 'tracking', + actions: [ + 'clearContext', + 'setIsDirtyToClean', + 'clearDisplaySetHydratedState', + 'clearAllMeasurements', + ], + }, + { + target: 'idle', + }, + ], SET_TRACKED_SERIES: [ { target: 'tracking', @@ -146,6 +160,15 @@ const machineConfiguration = { target: 'tracking', }, ], + CHECK_DIRTY: { + target: 'promptHasDirtyAnnotations', + cond: 'hasDirtyAndSimplified', + }, + PROMPT_HYDRATE_SR: { + target: 'promptHydrateStructuredReport', + cond: 'isSimplifiedConfig', + actions: ['clearAllMeasurements', 'clearDisplaySetHydratedState'], + }, }, }, promptTrackNewSeries: { @@ -214,6 +237,16 @@ const machineConfiguration = { invoke: { src: 'promptSaveReport', onDone: [ + { + target: 'tracking', + actions: [ + 'clearAllMeasurements', + 'clearDisplaySetHydratedState', + 'setIsDirty', + 'updatedViewports', + ], + cond: 'simplifiedAndLoadSR', + }, // "clicked the save button" // - should clear all measurements // - show DICOM SR @@ -312,6 +345,28 @@ const machineConfiguration = { ], }, }, + promptHasDirtyAnnotations: { + invoke: { + src: 'promptHasDirtyAnnotations', + onDone: [ + { + target: 'tracking', + actions: [ + 'clearAllMeasurements', + 'clearDisplaySetHydratedState', + 'setIsDirty', + 'updatedViewports', + ], + cond: 'shouldSetStudyAndSeries', + }, + { + target: 'promptSaveReport', + cond: 'shouldPromptSaveReport', + }, + { target: 'tracking' }, + ], + }, + }, }, strict: true, }; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js index 2638206e6..756d642eb 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js @@ -9,17 +9,29 @@ const RESPONSE = { SET_STUDY_AND_SERIES: 3, }; +export const measurementTrackingMode = { + STANDARD: 'standard', + SIMPLIFIED: 'simplified', + NONE: 'none', +}; + function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) { - const { uiViewportDialogService } = servicesManager.services; + const { uiViewportDialogService, customizationService } = servicesManager.services; const appConfig = extensionManager._appConfig; // When the state change happens after a promise, the state machine sends the retult in evt.data; // In case of direct transition to the state, the state machine sends the data in evt; const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt; return new Promise(async function (resolve, reject) { - let promptResult = appConfig?.disableConfirmationPrompts - ? RESPONSE.SET_STUDY_AND_SERIES - : await _askTrackMeasurements(uiViewportDialogService, viewportId); + const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD; + const noTrackingMode = appConfig?.measurementTrackingMode === measurementTrackingMode.NONE; + let promptResult; + + promptResult = noTrackingMode + ? RESPONSE.NO_NEVER + : standardMode + ? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) + : RESPONSE.SET_STUDY_AND_SERIES; resolve({ userResponse: promptResult, @@ -30,9 +42,11 @@ function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) { }); } -function _askTrackMeasurements(uiViewportDialogService, viewportId) { +function _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) { return new Promise(function (resolve, reject) { - const message = i18n.t('MeasurementTable:Track measurements for this series?'); + const message = customizationService.getCustomization( + 'viewportNotification.beginTrackingMessage' + ); const actions = [ { id: 'prompt-begin-tracking-cancel', diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHasDirtyAnnotations.ts b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHasDirtyAnnotations.ts new file mode 100644 index 000000000..7f4a3acc5 --- /dev/null +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHasDirtyAnnotations.ts @@ -0,0 +1,81 @@ +const RESPONSE = { + NO_NEVER: -1, + CANCEL: 0, + CREATE_REPORT: 1, + ADD_SERIES: 2, + SET_STUDY_AND_SERIES: 3, + NO_NOT_FOR_SERIES: 4, +}; + +function promptHasDirtyAnnotations({ servicesManager }: withAppTypes, ctx, evt) { + const { viewportId, displaySetInstanceUID } = evt.data || evt; + + return new Promise(async function (resolve, reject) { + const { uiViewportDialogService, customizationService } = servicesManager.services; + + const promptResult = await _askSaveDiscardOrCancel( + uiViewportDialogService, + customizationService, + viewportId + ); + + resolve({ + displaySetInstanceUID, + userResponse: promptResult, + viewportId, + isBackupSave: false, + }); + }); +} + +function _askSaveDiscardOrCancel( + UIViewportDialogService: AppTypes.UIViewportDialogService, + customizationService: AppTypes.CustomizationService, + viewportId +) { + return new Promise(function (resolve, reject) { + const message = customizationService.getCustomization( + 'viewportNotification.discardDirtyMessage' + ); + const actions = [ + { id: 'cancel', type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL }, + { + id: 'discard-existing', + type: 'secondary', + text: 'No, discard existing', + value: RESPONSE.SET_STUDY_AND_SERIES, + }, + { + id: 'save-existing', + type: 'primary', + text: 'Yes', + value: RESPONSE.CREATE_REPORT, + }, + ]; + const onSubmit = result => { + UIViewportDialogService.hide(); + resolve(result); + }; + + UIViewportDialogService.show({ + viewportId, + id: 'measurement-tracking-prompt-dirty-measurement', + type: 'info', + message, + actions, + onSubmit, + onOutsideClick: () => { + UIViewportDialogService.hide(); + resolve(RESPONSE.CANCEL); + }, + onKeyPress: event => { + if (event.key === 'Enter') { + const action = actions.find(action => action.id === 'save-existing'); + onSubmit(action.value); + } + }, + }); + }); +} + +export default promptHasDirtyAnnotations; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js index 4fd6640f7..0c1ea2602 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js @@ -1,5 +1,6 @@ import { hydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr'; import { ButtonEnums } from '@ohif/ui'; +import { measurementTrackingMode } from './promptBeginTracking'; const RESPONSE = { NO_NEVER: -1, @@ -16,13 +17,16 @@ function promptHydrateStructuredReport( ctx, evt ) { - const { uiViewportDialogService, displaySetService } = servicesManager.services; + const { uiViewportDialogService, displaySetService, customizationService } = + servicesManager.services; const { viewportId, displaySetInstanceUID } = evt; const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); return new Promise(async function (resolve, reject) { - const promptResult = appConfig?.disableConfirmationPrompts - ? RESPONSE.HYDRATE_REPORT - : await _askTrackMeasurements(uiViewportDialogService, viewportId); + const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD; + + const promptResult = standardMode + ? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) + : RESPONSE.HYDRATE_REPORT; // Need to do action here... So we can set state... let StudyInstanceUID, SeriesInstanceUIDs; @@ -49,9 +53,9 @@ function promptHydrateStructuredReport( }); } -function _askTrackMeasurements(uiViewportDialogService, viewportId) { +function _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) { return new Promise(function (resolve, reject) { - const message = 'Do you want to continue tracking measurements for this study?'; + const message = customizationService.getCustomization('viewportNotification.hydrateSRMessage'); const actions = [ { id: 'no-hydrate', diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js index d5e5575c5..1a430549d 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js @@ -1,4 +1,5 @@ import { ButtonEnums } from '@ohif/ui'; +import { measurementTrackingMode } from './promptBeginTracking'; const RESPONSE = { NO_NEVER: -1, @@ -10,17 +11,22 @@ const RESPONSE = { }; function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) { - const { UIViewportDialogService } = servicesManager.services; + const { UIViewportDialogService, customizationService } = servicesManager.services; // When the state change happens after a promise, the state machine sends the retult in evt.data; // In case of direct transition to the state, the state machine sends the data in evt; const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt; return new Promise(async function (resolve, reject) { - let promptResult = await _askShouldAddMeasurements(UIViewportDialogService, viewportId); + const appConfig = extensionManager._appConfig; + + const showPrompt = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD; + let promptResult = showPrompt + ? await _askShouldAddMeasurements(UIViewportDialogService, customizationService, viewportId) + : RESPONSE.ADD_SERIES; if (promptResult === RESPONSE.CREATE_REPORT) { promptResult = ctx.isDirty - ? await _askSaveDiscardOrCancel(UIViewportDialogService, viewportId) + ? await _askSaveDiscardOrCancel(UIViewportDialogService, customizationService, viewportId) : RESPONSE.SET_STUDY_AND_SERIES; } @@ -34,9 +40,11 @@ function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) { }); } -function _askShouldAddMeasurements(uiViewportDialogService, viewportId) { +function _askShouldAddMeasurements(uiViewportDialogService, customizationService, viewportId) { return new Promise(function (resolve, reject) { - const message = 'Do you want to add this measurement to the existing report?'; + const message = customizationService.getCustomization( + 'viewportNotification.trackNewSeriesMessage' + ); const actions = [ { type: ButtonEnums.type.secondary, @@ -73,10 +81,12 @@ function _askShouldAddMeasurements(uiViewportDialogService, viewportId) { }); } -function _askSaveDiscardOrCancel(UIViewportDialogService, viewportId) { +function _askSaveDiscardOrCancel(UIViewportDialogService, customizationService, viewportId) { return new Promise(function (resolve, reject) { - const message = - 'You have existing tracked measurements. What would you like to do with your existing tracked measurements?'; + const message = customizationService.getCustomization( + 'viewportNotification.discardSeriesMessage' + ); + const actions = [ { type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL }, { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.ts b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.ts index 1ce586999..954d9146a 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.ts +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.ts @@ -1,4 +1,5 @@ import i18n from 'i18next'; +import { measurementTrackingMode } from './promptBeginTracking'; const RESPONSE = { NO_NEVER: -1, @@ -10,18 +11,26 @@ const RESPONSE = { }; function promptTrackNewStudy({ servicesManager, extensionManager }: withAppTypes, ctx, evt) { - const { uiViewportDialogService } = servicesManager.services; + const { uiViewportDialogService, customizationService } = servicesManager.services; // When the state change happens after a promise, the state machine sends the retult in evt.data; // In case of direct transition to the state, the state machine sends the data in evt; const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt; return new Promise(async function (resolve, reject) { - let promptResult = await _askTrackMeasurements(uiViewportDialogService, viewportId); + const appConfig = extensionManager._appConfig; + + const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD; + const simplifiedMode = + appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED; + let promptResult = standardMode + ? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) + : RESPONSE.SET_STUDY_AND_SERIES; if (promptResult === RESPONSE.SET_STUDY_AND_SERIES) { - promptResult = ctx.isDirty - ? await _askSaveDiscardOrCancel(uiViewportDialogService, viewportId) - : RESPONSE.SET_STUDY_AND_SERIES; + promptResult = + ctx.isDirty && (standardMode || simplifiedMode) + ? await _askSaveDiscardOrCancel(uiViewportDialogService, customizationService, viewportId) + : RESPONSE.SET_STUDY_AND_SERIES; } resolve({ @@ -36,10 +45,13 @@ function promptTrackNewStudy({ servicesManager, extensionManager }: withAppTypes function _askTrackMeasurements( UIViewportDialogService: AppTypes.UIViewportDialogService, + customizationService: AppTypes.CustomizationService, viewportId ) { return new Promise(function (resolve, reject) { - const message = i18n.t('MeasurementTable:Track measurements for this series?'); + const message = customizationService.getCustomization( + 'viewportNotification.trackNewStudyMessage' + ); const actions = [ { type: 'cancel', text: i18n.t('MeasurementTable:No'), value: RESPONSE.CANCEL }, { @@ -80,11 +92,13 @@ function _askTrackMeasurements( function _askSaveDiscardOrCancel( UIViewportDialogService: AppTypes.UIViewportDialogService, + customizationService: AppTypes.CustomizationService, viewportId ) { return new Promise(function (resolve, reject) { - const message = - 'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?'; + const message = customizationService.getCustomization( + 'viewportNotification.discardStudyMessage' + ); const actions = [ { type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL }, { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptWrapperFunctions.ts b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptWrapperFunctions.ts new file mode 100644 index 000000000..47682772d --- /dev/null +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptWrapperFunctions.ts @@ -0,0 +1,83 @@ +const promptBeginTrackingWrapper = ({ servicesManager, extensionManager }, ctx, evt) => { + const { customizationService } = servicesManager.services; + const promptBeginTracking = customizationService.getCustomization( + 'measurement.promptBeginTracking' + ); + return promptBeginTracking({ servicesManager, extensionManager }, ctx, evt); +}; + +const promptHydrateStructuredReportWrapper = ( + { servicesManager, extensionManager, commandsManager, appConfig }, + ctx, + evt +) => { + const { customizationService } = servicesManager.services; + const promptHydrateStructuredReport = customizationService.getCustomization( + 'measurement.promptHydrateStructuredReport' + ); + return promptHydrateStructuredReport( + { servicesManager, extensionManager, commandsManager, appConfig }, + ctx, + evt + ); +}; + +const promptTrackNewSeriesWrapper = ({ servicesManager, extensionManager }, ctx, evt) => { + const { customizationService } = servicesManager.services; + const promptTrackNewSeries = customizationService.getCustomization( + 'measurement.promptTrackNewSeries' + ); + return promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt); +}; + +const promptTrackNewStudyWrapper = ({ servicesManager, extensionManager }, ctx, evt) => { + const { customizationService } = servicesManager.services; + const promptTrackNewStudy = customizationService.getCustomization( + 'measurement.promptTrackNewStudy' + ); + return promptTrackNewStudy({ servicesManager, extensionManager }, ctx, evt); +}; + +const promptLabelAnnotationWrapper = ({ servicesManager }, ctx, evt) => { + const { customizationService } = servicesManager.services; + const promptLabelAnnotation = customizationService.getCustomization( + 'measurement.promptLabelAnnotation' + ); + return promptLabelAnnotation({ servicesManager }, ctx, evt); +}; + +const promptSaveReportWrapper = ( + { servicesManager, commandsManager, extensionManager }, + ctx, + evt +) => { + const { customizationService } = servicesManager.services; + const promptSaveReport = customizationService.getCustomization('measurement.promptSaveReport'); + return promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt); +}; + +const promptHasDirtyAnnotationsWrapper = ( + { servicesManager, commandsManager, extensionManager }, + ctx, + evt +) => { + const { customizationService } = servicesManager.services; + const promptHasDirtyAnnotations = customizationService.getCustomization( + 'measurement.promptHasDirtyAnnotations' + ); + return promptHasDirtyAnnotations( + { servicesManager, commandsManager, extensionManager }, + ctx, + evt + ); +}; + +export { + promptBeginTrackingWrapper, + promptHydrateStructuredReportWrapper, + promptTrackNewSeriesWrapper, + promptTrackNewStudyWrapper, + promptLabelAnnotationWrapper, + promptSaveReportWrapper, + promptHasDirtyAnnotationsWrapper, +}; diff --git a/extensions/measurement-tracking/src/customizations/measurementTrackingPrompts.tsx b/extensions/measurement-tracking/src/customizations/measurementTrackingPrompts.tsx new file mode 100644 index 000000000..25d780ddf --- /dev/null +++ b/extensions/measurement-tracking/src/customizations/measurementTrackingPrompts.tsx @@ -0,0 +1,16 @@ +import promptBeginTracking from '../contexts/TrackedMeasurementsContext/promptBeginTracking'; +import promptHasDirtyAnnotations from '../contexts/TrackedMeasurementsContext/promptHasDirtyAnnotations'; +import promptHydrateStructuredReport from '../contexts/TrackedMeasurementsContext/promptHydrateStructuredReport'; +import promptTrackNewSeries from '../contexts/TrackedMeasurementsContext/promptTrackNewSeries'; +import promptTrackNewStudy from '../contexts/TrackedMeasurementsContext/promptTrackNewStudy'; +import { promptLabelAnnotation, promptSaveReport } from '@ohif/extension-default'; + +export default { + 'measurement.promptBeginTracking': promptBeginTracking, + 'measurement.promptHydrateStructuredReport': promptHydrateStructuredReport, + 'measurement.promptTrackNewSeries': promptTrackNewSeries, + 'measurement.promptTrackNewStudy': promptTrackNewStudy, + 'measurement.promptLabelAnnotation': promptLabelAnnotation, + 'measurement.promptSaveReport': promptSaveReport, + 'measurement.promptHasDirtyAnnotations': promptHasDirtyAnnotations, +}; diff --git a/extensions/measurement-tracking/src/customizations/studyBrowserCustomization.ts b/extensions/measurement-tracking/src/customizations/studyBrowserCustomization.ts new file mode 100644 index 000000000..f0da10982 --- /dev/null +++ b/extensions/measurement-tracking/src/customizations/studyBrowserCustomization.ts @@ -0,0 +1,64 @@ +import { measurementTrackingMode } from '../contexts/TrackedMeasurementsContext/promptBeginTracking'; + +type CheckHasDirtyAndSimplifiedModeProps = { + servicesManager: AppTypes.ServicesManager; + appConfig: AppTypes.Config; + displaySetInstanceUID: string; +}; + +const onDoubleClickHandler = { + callbacks: [ + ({ activeViewportId, servicesManager, isHangingProtocolLayout, appConfig }) => + async displaySetInstanceUID => { + const { hangingProtocolService, viewportGridService, uiNotificationService } = + servicesManager.services; + let updatedViewports = []; + const viewportId = activeViewportId; + const haveDirtyMeasurementsInSimplifiedMode = checkHasDirtyAndSimplifiedMode({ + servicesManager, + appConfig, + displaySetInstanceUID, + }); + + try { + if (!haveDirtyMeasurementsInSimplifiedMode) { + updatedViewports = hangingProtocolService.getViewportsRequireUpdate( + viewportId, + displaySetInstanceUID, + isHangingProtocolLayout + ); + viewportGridService.setDisplaySetsForViewports(updatedViewports); + } + } catch (error) { + console.warn(error); + uiNotificationService.show({ + title: 'Thumbnail Double Click', + message: 'The selected display sets could not be added to the viewport.', + type: 'error', + duration: 3000, + }); + } + }, + ], +}; + +const customOnDropHandlerCallback = async props => { + const handled = checkHasDirtyAndSimplifiedMode(props); + return Promise.resolve({ handled }); +}; + +const checkHasDirtyAndSimplifiedMode = (props: CheckHasDirtyAndSimplifiedModeProps) => { + const { servicesManager, appConfig, displaySetInstanceUID } = props; + const simplifiedMode = appConfig.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED; + const { measurementService, displaySetService } = servicesManager.services; + const measurements = measurementService.getMeasurements(); + const haveDirtyMeasurements = + measurements.some(m => m.isDirty) || + (measurements.length && measurementService.getIsMeasurementDeletedIndividually()); + const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); + const hasDirtyAndSimplifiedMode = + displaySet.Modality === 'SR' && simplifiedMode && haveDirtyMeasurements; + return hasDirtyAndSimplifiedMode; +}; + +export { onDoubleClickHandler, customOnDropHandlerCallback }; diff --git a/extensions/measurement-tracking/src/getCustomizationModule.ts b/extensions/measurement-tracking/src/getCustomizationModule.ts new file mode 100644 index 000000000..d2bd35775 --- /dev/null +++ b/extensions/measurement-tracking/src/getCustomizationModule.ts @@ -0,0 +1,10 @@ +import measurementTrackingPrompts from './customizations/measurementTrackingPrompts'; + +export default function getCustomizationModule() { + return [ + { + name: 'default', + value: { ...measurementTrackingPrompts }, + }, + ]; +} diff --git a/extensions/measurement-tracking/src/index.tsx b/extensions/measurement-tracking/src/index.tsx index 3cced774b..7f87c7519 100644 --- a/extensions/measurement-tracking/src/index.tsx +++ b/extensions/measurement-tracking/src/index.tsx @@ -6,6 +6,12 @@ import getViewportModule from './getViewportModule'; import { id } from './id.js'; import { ViewportActionButton } from '@ohif/ui'; import i18n from '@ohif/i18n'; +import { measurementTrackingMode } from './contexts/TrackedMeasurementsContext/promptBeginTracking'; +import getCustomizationModule from './getCustomizationModule'; +import { + onDoubleClickHandler, + customOnDropHandlerCallback, +} from './customizations/studyBrowserCustomization'; const measurementTrackingExtension = { /** @@ -18,8 +24,15 @@ const measurementTrackingExtension = { getViewportModule, onModeEnter({ servicesManager }) { - const { toolbarService } = servicesManager.services; - + const { toolbarService, customizationService } = servicesManager.services; + customizationService.setCustomizations({ + 'studyBrowser.thumbnailDoubleClickCallback': { + $set: onDoubleClickHandler, + }, + customOnDropHandler: { + $set: customOnDropHandlerCallback, + }, + }); toolbarService.addButtons( [ { @@ -38,6 +51,9 @@ const measurementTrackingExtension = { true // replace the button if it is already defined ); }, + getCustomizationModule, }; export default measurementTrackingExtension; + +export { measurementTrackingMode }; diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx index 900c528f7..956e6a37b 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Dialog, ButtonEnums } from '@ohif/ui'; import { utils } from '@ohif/core'; import { AccordionTrigger, MeasurementTable, useViewportGrid } from '@ohif/ui-next'; import { @@ -11,18 +12,40 @@ import { } from '@ohif/extension-cornerstone'; import { useTrackedMeasurements } from '../getContextModule'; +import { UntrackSeriesModal } from './PanelStudyBrowserTracking/untrackSeriesModal'; const { filterAnd, filterPlanarMeasurement, filterMeasurementsBySeriesUID } = utils.MeasurementFilters; function PanelMeasurementTableTracking(props) { const [viewportGrid] = useViewportGrid(); + const { measurementService, uiModalService } = props.servicesManager.services; const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements(); const { trackedStudy, trackedSeries } = trackedMeasurements.context; const measurementFilter = trackedStudy ? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries)) : filterPlanarMeasurement; + const onUntrackConfirm = () => { + sendTrackedMeasurementsEvent('UNTRACK_ALL', {}); + }; + + const onDelete = () => { + const hasDirtyMeasurements = measurementService + .getMeasurements() + .some(measurement => measurement.isDirty); + hasDirtyMeasurements + ? uiModalService.show({ + title: 'Untrack Study', + content: UntrackSeriesModal, + contentProps: { + onConfirm: onUntrackConfirm, + message: 'Are you sure you want to untrack study and delete all measurements?', + }, + }) + : onUntrackConfirm(); + }; + const EmptyComponent = () => (
( @@ -51,7 +75,10 @@ function PanelMeasurementTableTracking(props) { className="px-0" >
- +
); @@ -68,7 +95,7 @@ function PanelMeasurementTableTracking(props) { key="trackingMeasurementsHeader" asChild={true} > -
+
{ + const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); + if (displaySet.Modality === 'SR') { + const activeViewportId = viewportGridService.getActiveViewportId(); + sendTrackedMeasurementsEvent('CHECK_DIRTY', { + viewportId: activeViewportId, + displaySetInstanceUID: displaySetInstanceUID, + }); + } + }; + + useEffect(() => { + const subscriptionOndropFired = viewportGridService.subscribe( + viewportGridService.EVENTS.VIEWPORT_ONDROP_HANDLED, + ({ eventData }) => { + checkDirtyMeasurements(eventData.displaySetInstanceUID); + } + ); + + return () => { + subscriptionOndropFired.unsubscribe(); + }; + }, []); const onClickUntrack = displaySetInstanceUID => { const onConfirm = () => { const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); @@ -50,6 +75,7 @@ export default function PanelStudyBrowserTracking({ content: UntrackSeriesModal, contentProps: { onConfirm, + message: 'Are you sure you want to untrack this series?', }, }); }; @@ -115,6 +141,7 @@ export default function PanelStudyBrowserTracking({ dataSource={dataSource} customMapDisplaySets={mapDisplaySetsWithTracking} onClickUntrack={onClickUntrack} + onDoubleClickThumbnailHandlerCallBack={checkDirtyMeasurements} /> ); } diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/untrackSeriesModal.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/untrackSeriesModal.tsx index 5b7091c84..bb7d1ccb2 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/untrackSeriesModal.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/untrackSeriesModal.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { FooterAction } from '@ohif/ui-next'; -export function UntrackSeriesModal({ hide, onConfirm }) { +export function UntrackSeriesModal({ hide, onConfirm, message }) { return (
-

Are you sure you want to untrack this series?

+

{message}

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

diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index db1c54498..b4e510ccc 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -47,6 +47,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { }; // Default the peer import function appConfig.peerImport ||= peerImport; + appConfig.measurementTrackingMode ||= 'standard'; const extensionManager = new ExtensionManager({ commandsManager, diff --git a/platform/app/src/components/ViewportGrid.tsx b/platform/app/src/components/ViewportGrid.tsx index e595a4fe0..0c4561af0 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -257,6 +257,7 @@ function ViewerViewportGrid(props: withAppTypes) { }, [viewports, _getUpdatedViewports]); const onDropHandler = (viewportId, { displaySetInstanceUID }) => { + const { viewportGridService } = servicesManager.services; const customOnDropHandler = customizationService.getCustomization('customOnDropHandler'); const dropHandlerPromise = customOnDropHandler({ ...props, @@ -264,13 +265,13 @@ function ViewerViewportGrid(props: withAppTypes) { displaySetInstanceUID, appConfig, }); - dropHandlerPromise.then(({ handled }) => { if (!handled) { const updatedViewports = _getUpdatedViewports(viewportId, displaySetInstanceUID); viewportGridService.setDisplaySetsForViewports(updatedViewports); } }); + viewportGridService.publishViewportOnDropHandled({ displaySetInstanceUID }); }; // Store previous isReferenceViewable values to avoid infinite loops diff --git a/platform/core/src/services/MeasurementService/MeasurementService.ts b/platform/core/src/services/MeasurementService/MeasurementService.ts index 81d39148b..1683bbba5 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.ts +++ b/platform/core/src/services/MeasurementService/MeasurementService.ts @@ -64,6 +64,7 @@ const MEASUREMENT_SCHEMA_KEYS = [ 'isSelected', 'textBox', 'referencedImageId', + 'isDirty', ]; const EVENTS = { @@ -92,6 +93,11 @@ const VALUE_TYPES = { ROI_THRESHOLD_MANUAL: 'value_type::roiThresholdManual', }; +enum MeasurementChangeType { + HandlesUpdated = 'HandlesUpdated', + LabelChange = 'LabelChange', +} + export type MeasurementFilter = (measurement) => boolean; /** @@ -121,6 +127,7 @@ class MeasurementService extends PubSubService { private measurements = new Map(); private unmappedMeasurements = new Map(); + private isMeasurementDeletedIndividually: boolean; private sources = {}; private mappings = {}; @@ -543,6 +550,11 @@ class MeasurementService extends PubSubService { uid: internalUID, }; + newMeasurement.isDirty = + sourceAnnotationDetail.changeType === MeasurementChangeType.HandlesUpdated || + sourceAnnotationDetail.changeType === MeasurementChangeType.LabelChange || + oldMeasurement?.isDirty; + if (oldMeasurement) { // TODO: Ultimately, each annotation should have a selected flag right from the source. // For now, it is just added in OHIF here and in setMeasurementSelected. @@ -586,6 +598,7 @@ class MeasurementService extends PubSubService { this.unmappedMeasurements.delete(measurementUID); this.measurements.delete(measurementUID); + this.isMeasurementDeletedIndividually = true; this._broadcastEvent(this.EVENTS.MEASUREMENT_REMOVED, { source, measurement: measurementUID, @@ -839,6 +852,14 @@ class MeasurementService extends PubSubService { notYetUpdatedAtSource: true, }); } + + public setIsMeasurementDeletedIndividually = isDeletedIndividually => { + this.isMeasurementDeletedIndividually = isDeletedIndividually; + }; + + public getIsMeasurementDeletedIndividually = () => { + return this.isMeasurementDeletedIndividually; + }; } export default MeasurementService; diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index ee828b74a..f39fec02b 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -7,6 +7,7 @@ class ViewportGridService extends PubSubService { GRID_STATE_CHANGED: 'event::gridStateChanged', GRID_SIZE_CHANGED: 'event::gridSizeChanged', VIEWPORTS_READY: 'event::viewportsReady', + VIEWPORT_ONDROP_HANDLED: 'event::viewportOnDropHandled', }; public static REGISTRATION = { @@ -141,6 +142,10 @@ class ViewportGridService extends PubSubService { this._broadcastEvent(this.EVENTS.VIEWPORTS_READY, {}); } + public publishViewportOnDropHandled(eventData) { + this._broadcastEvent(this.EVENTS.VIEWPORT_ONDROP_HANDLED, { eventData }); + } + public setActiveViewportId(id: string) { if (id === this.getActiveViewportId()) { return; diff --git a/platform/core/src/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts index 826e5de13..16125b04c 100644 --- a/platform/core/src/types/AppTypes.ts +++ b/platform/core/src/types/AppTypes.ts @@ -114,6 +114,7 @@ declare global { days?: number; }; groupEnabledModesFirst?: boolean; + measurementTrackingMode?: 'standard' | 'simplified' | 'none'; disableConfirmationPrompts?: boolean; showPatientInfo?: 'visible' | 'visibleCollapsed' | 'disabled' | 'visibleReadOnly'; requestTransferSyntaxUID?: string; diff --git a/platform/docs/docs/assets/img/promptAddSeriesContent.png b/platform/docs/docs/assets/img/promptAddSeriesContent.png new file mode 100644 index 000000000..53c721160 Binary files /dev/null and b/platform/docs/docs/assets/img/promptAddSeriesContent.png differ diff --git a/platform/docs/docs/assets/img/promptBeginTrackingContent.png b/platform/docs/docs/assets/img/promptBeginTrackingContent.png new file mode 100644 index 000000000..d5a2aa261 Binary files /dev/null and b/platform/docs/docs/assets/img/promptBeginTrackingContent.png differ diff --git a/platform/docs/docs/assets/img/promptDiscardDirtyContent.png b/platform/docs/docs/assets/img/promptDiscardDirtyContent.png new file mode 100644 index 000000000..aedd30e3e Binary files /dev/null and b/platform/docs/docs/assets/img/promptDiscardDirtyContent.png differ diff --git a/platform/docs/docs/assets/img/promptDiscardSeriesContent.png b/platform/docs/docs/assets/img/promptDiscardSeriesContent.png new file mode 100644 index 000000000..cad713435 Binary files /dev/null and b/platform/docs/docs/assets/img/promptDiscardSeriesContent.png differ diff --git a/platform/docs/docs/assets/img/promptDiscardStudyContent.png b/platform/docs/docs/assets/img/promptDiscardStudyContent.png new file mode 100644 index 000000000..7cbafd216 Binary files /dev/null and b/platform/docs/docs/assets/img/promptDiscardStudyContent.png differ diff --git a/platform/docs/docs/assets/img/promptRtstructContent.png b/platform/docs/docs/assets/img/promptRtstructContent.png new file mode 100644 index 000000000..71af4eb47 Binary files /dev/null and b/platform/docs/docs/assets/img/promptRtstructContent.png differ diff --git a/platform/docs/docs/assets/img/promptSRTrackingContent.png b/platform/docs/docs/assets/img/promptSRTrackingContent.png new file mode 100644 index 000000000..365f62411 Binary files /dev/null and b/platform/docs/docs/assets/img/promptSRTrackingContent.png differ diff --git a/platform/docs/docs/assets/img/promptTrackStudyContent.png b/platform/docs/docs/assets/img/promptTrackStudyContent.png new file mode 100644 index 000000000..d5a2aa261 Binary files /dev/null and b/platform/docs/docs/assets/img/promptTrackStudyContent.png differ diff --git a/platform/docs/docs/assets/img/promptsegmentationContent.png b/platform/docs/docs/assets/img/promptsegmentationContent.png new file mode 100644 index 000000000..0091b988b Binary files /dev/null and b/platform/docs/docs/assets/img/promptsegmentationContent.png differ diff --git a/platform/docs/docs/configuration/configurationFiles.md b/platform/docs/docs/configuration/configurationFiles.md index d3289dbb0..be6829c74 100644 --- a/platform/docs/docs/configuration/configurationFiles.md +++ b/platform/docs/docs/configuration/configurationFiles.md @@ -125,7 +125,7 @@ Here are a list of some options available: - `investigationalUseDialog`: This should contain an object with `option` value, it can be either `always` which always shows the dialog once per session, `never` which never shows the dialog, or `configure` which shows the dialog once and won't show it again until a set number of days defined by the user, if it's set to configure, you are required to add an additional property `days` which is the number of days to wait before showing the dialog again. - `groupEnabledModesFirst`: boolean, if set to true, all valid modes for the study get grouped together first, then the rest of the modes. If false, all modes are shown in the order they are defined in the configuration. - `experimentalStudyBrowserSort`: boolean, if set to true, you will get the experimental StudyBrowserSort component in the UI, which displays a list of sort functions that the displaySets can be sorted by, the sort reflects in all part of the app including the thumbnail/study panel. These sort functions are defined in the customizationModule and can be expanded by users. -- `disableConfirmationPrompts`: boolean, if set to true, it skips confirmation prompts for measurement tracking and hydration. +- `disableConfirmationPrompts`: boolean, if set to true, it skips confirmation prompts for segmentation related prompts. - `showPatientInfo`: string, if set to 'visible', the patient info header will be shown and its initial state is expanded. If set to 'visibleCollapsed', the patient info header will be shown but it's initial state is collapsed. If set to 'disabled', the patient info header will never be shown, and if set to 'visibleReadOnly', the patient info header will be shown and always expanded. - `requestTransferSyntaxUID` : Request a specific Transfer syntax from dicom web server ex: 1.2.840.10008.1.2.4.80 (applied only if acceptHeader is not set) - `omitQuotationForMultipartRequest`: Some servers (e.g., .NET) require the `multipart/related` request to be sent without quotation marks. Defaults to `false`. If your server doesn't require this, then setting this flag to `true` might improve performance (by removing the need for preflight requests). Also note that diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index 6a8cefe73..a7dd8c8a5 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -27,6 +27,15 @@ 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'; +import promptBeginTrackingContent from '../../../assets/img/promptBeginTrackingContent.png'; +import promptAddSeriesContent from '../../../assets/img/promptAddSeriesContent.png'; +import promptSRTrackingContent from '../../../assets/img/promptSRTrackingContent.png'; +import promptsegmentationContent from '../../../assets/img/promptsegmentationContent.png'; +import promptRtstructContent from '../../../assets/img/promptRtstructContent.png'; +import promptDiscardStudyContent from '../../../assets/img/promptDiscardStudyContent.png'; +import promptDiscardSeriesContent from '../../../assets/img/promptDiscardSeriesContent.png'; +import promptDiscardDirtyContent from '../../../assets/img/promptDiscardDirtyContent.png'; +import promptTrackStudyContent from '../../../assets/img/promptTrackStudyContent.png'; export const viewportOverlayCustomizations = [ { @@ -952,6 +961,296 @@ window.config = { }; `, }, + { + id: 'viewportNotification.beginTrackingMessage', + description: 'Define the content to be displayed in begin tracking prompt', + default: 'Track measurements for this series?', + image: [promptBeginTrackingContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.beginTrackingMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.trackNewSeriesMessage', + description: 'Define the content to be displayed in track new series prompt', + default: 'Do you want to add this measurement to the existing report?', + image: [promptAddSeriesContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.trackNewSeriesMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.discardSeriesMessage', + description: 'Define the content to be displayed in discard series prompt', + default: + 'You have existing tracked measurements. What would you like to do with your existing tracked measurements?', + image: [promptDiscardSeriesContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.discardSeriesMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.trackNewStudyMessage', + description: 'Define the content to be displayed in track new study prompt', + default: 'Track measurements for this series?', + image: [promptTrackStudyContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.trackNewStudyMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.discardStudyMessage', + description: 'Define the content to be displayed in discard study prompt', + default: + 'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?', + image: [promptDiscardStudyContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.discardStudyMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.hydrateSRMessage', + description: 'Define the content to be displayed in hydrate SR prompt', + default: 'Do you want to continue tracking measurements for this study?', + image: [promptSRTrackingContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.hydrateSRMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.hydrateRTMessage', + description: 'Define the content to be displayed in hydrate RT prompt', + default: 'Do you want to open this Segmentation?', + image: [promptRtstructContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.hydrateRTMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.hydrateSEGMessage', + description: 'Define the content to be displayed in hydrate SEG prompt', + default: 'Do you want to open this Segmentation?', + image: [promptsegmentationContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.hydrateSEGMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'viewportNotification.discardDirtyMessage', + description: 'Define the content to be displayed in hydrate SR prompt', + default: 'There are unsaved measurements. Do you want to save it?', + image: [promptDiscardDirtyContent], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'viewportNotification.discardDirtyMessage': { + $set: CustomizedComponent, + }, + }, + ], +}; + `, + }, + { + id: 'measurement.promptBeginTracking', + description: + 'Define the functionality to connect with the measurement tracking machine on begin measurement tracking', + default: 'promptBeginTracking', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'measurement.promptBeginTracking': { + $set: customFunction, + }, + }, + ], +}; + `, + }, + { + id: 'measurement.promptHydrateStructuredReport', + description: + 'Define the functionality to connect with the measurement tracking machine on hydrate SR', + default: 'promptHydrateStructuredReport', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'measurement.promptHydrateStructuredReport': { + $set: customFunction, + }, + }, + ], +}; + `, + }, + { + id: 'measurement.promptTrackNewSeries', + description: + 'Define the functionality to connect with the measurement tracking machine on tracking new series', + default: 'promptTrackNewSeries', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'measurement.promptTrackNewSeries': { + $set: customFunction, + }, + }, + ], +}; + `, + }, + { + id: 'measurement.promptTrackNewStudy', + description: + 'Define the functionality to connect with the measurement tracking machine on tracking new study', + default: 'promptTrackNewStudy', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'measurement.promptTrackNewStudy': { + $set: customFunction, + }, + }, + ], +}; + `, + }, + { + id: 'measurement.promptLabelAnnotation', + description: + 'Define the functionality to connect with the measurement tracking machine on begin measurement tracking', + default: 'promptLabelAnnotation', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'measurement.promptLabelAnnotation': { + $set: customFunction, + }, + }, + ], +}; + `, + }, + { + id: 'measurement.promptSaveReport', + description: + 'Define the functionality to connect with the measurement tracking machine on save SR report', + default: 'promptSaveReport', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'measurement.promptSaveReport': { + $set: customFunction, + }, + }, + ], +}; + `, + }, + { + id: 'measurement.promptHasDirtyAnnotations', + description: + 'Define the functionality to connect with the measurement tracking machine on there are dirty annotations', + default: 'promptHasDirtyAnnotations', + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'measurement.promptHasDirtyAnnotations': { + $set: customFunction, + }, + }, + ], +}; + `, + }, ]; export const segmentationCustomizations = [ diff --git a/platform/i18n/src/locales/en-US/MeasurementTable.json b/platform/i18n/src/locales/en-US/MeasurementTable.json index 008a8c8a4..424317274 100644 --- a/platform/i18n/src/locales/en-US/MeasurementTable.json +++ b/platform/i18n/src/locales/en-US/MeasurementTable.json @@ -8,5 +8,11 @@ "NonTargets": "NonTargets", "Relabel": "Relabel", "Targets": "Targets", - "Track measurements for this series?": "Track measurements for this series?" + "Track measurements for this series?": "Track measurements for this series?", + "Do you want to add this measurement to the existing report?": "Do you want to add this measurement to the existing report?", + "You have existing tracked measurements. What would you like to do with your existing tracked measurements?": "You have existing tracked measurements. What would you like to do with your existing tracked measurements?", + "Measurements cannot span across multiple studies. Do you want to save your tracked measurements?": "Measurements cannot span across multiple studies. Do you want to save your tracked measurements?", + "Do you want to continue tracking measurements for this study?": "Do you want to continue tracking measurements for this study?", + "Do you want to open this Segmentation?": "Do you want to open this Segmentation?", + "There are unsaved measurements. Do you want to save it?": "There are unsaved measurements. Do you want to save it?" }