diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index d70da2b6e..38aa50366 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -16,7 +16,7 @@ import { Types as OhifTypes } from '@ohif/core'; import { vec3, mat4 } from 'gl-matrix'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; -import callInputDialog from './utils/callInputDialog'; +import { callLabelAutocompleteDialog, showLabelAnnotationPopup } from './utils/callInputDialog'; import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync'; import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; @@ -40,6 +40,7 @@ function commandsModule({ cornerstoneViewportService, uiNotificationService, measurementService, + customizationService, colorbarService, hangingProtocolService, syncGroupService, @@ -140,23 +141,18 @@ function commandsModule({ * on the measurement with a response if not cancelled. */ setMeasurementLabel: ({ uid }) => { + const labelConfig = customizationService.get('measurementLabels'); const measurement = measurementService.getMeasurement(uid); - - callInputDialog( - uiDialogService, - measurement, - (label, actionId) => { - if (actionId === 'cancel') { - return; - } - - const updatedMeasurement = Object.assign({}, measurement, { - label, - }); - - measurementService.update(updatedMeasurement.uid, updatedMeasurement, true); - }, - false + showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then( + (val: Map) => { + measurementService.update( + uid, + { + ...val, + }, + true + ); + } ); }, @@ -232,8 +228,9 @@ function commandsModule({ viewportGridService.setActiveViewportId(viewportId); }, - arrowTextCallback: ({ callback, data }) => { - callInputDialog(uiDialogService, data, callback); + arrowTextCallback: ({ callback, data, uid }) => { + const labelConfig = customizationService.get('measurementLabels'); + callLabelAutocompleteDialog(uiDialogService, callback, {}, labelConfig); }, toggleCine: () => { const { viewports } = viewportGridService.getState(); diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 6a4fdffc1..eb403d3d1 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -31,6 +31,7 @@ import { id } from './id'; import { measurementMappingUtils } from './utils/measurementServiceMappings'; import type { PublicViewportOptions } from './services/ViewportService/Viewport'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; +import { showLabelAnnotationPopup } from './utils/callInputDialog'; import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService'; import { ViewportActionCornersProvider } from './contextProviders/ViewportActionCornersProvider'; @@ -126,6 +127,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { }, getEnabledElement, dicomLoaderService, + showLabelAnnotationPopup }, }, { diff --git a/extensions/cornerstone/src/utils/callInputDialog.tsx b/extensions/cornerstone/src/utils/callInputDialog.tsx index 37d5021c5..e450536ea 100644 --- a/extensions/cornerstone/src/utils/callInputDialog.tsx +++ b/extensions/cornerstone/src/utils/callInputDialog.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Input, Dialog, ButtonEnums } from '@ohif/ui'; +import { Input, Dialog, ButtonEnums, LabellingFlow } from '@ohif/ui'; /** * @@ -13,6 +13,7 @@ import { Input, Dialog, ButtonEnums } from '@ohif/ui'; * @param {string?} dialogConfig.dialogTitle - title of the input dialog * @param {string?} dialogConfig.inputLabel - show label above the input */ + function callInputDialog( uiDialogService, data, @@ -88,4 +89,73 @@ function callInputDialog( } } +export function callLabelAutocompleteDialog(uiDialogService, callback, dialogConfig, labelConfig) { + 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', + isDraggable: false, + showOverlay: true, + content: LabellingFlow, + defaultPosition: { + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }, + contentProps: { + labellingDoneCallback: labellingDoneCallback, + measurementData: { label: '' }, + componentClassName: {}, + labelData: dropDownItems, + exclusive: exclusive, + }, + }); +} + +export function showLabelAnnotationPopup(measurement, uiDialogService, labelConfig) { + const exclusive = labelConfig ? labelConfig.exclusive : false; + const dropDownItems = labelConfig ? labelConfig.items : []; + return new Promise>((resolve, reject) => { + const labellingDoneCallback = value => { + uiDialogService.dismiss({ id: 'select-annotation' }); + if (typeof value === 'string') { + measurement.label = value; + } + resolve(measurement); + }; + + uiDialogService.create({ + id: 'select-annotation', + isDraggable: false, + showOverlay: true, + content: LabellingFlow, + defaultPosition: { + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }, + contentProps: { + labellingDoneCallback: labellingDoneCallback, + measurementData: measurement, + componentClassName: {}, + labelData: dropDownItems, + exclusive: exclusive, + }, + }); + }); +} + export default callInputDialog; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx index bc83efa7d..4aa2cee42 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { Machine } from 'xstate'; import { useMachine } from '@xstate/react'; import { useViewportGrid } from '@ohif/ui'; -import { machineConfiguration, defaultOptions } from './measurementTrackingMachine'; +import { machineConfiguration, defaultOptions, RESPONSE } from './measurementTrackingMachine'; import promptBeginTracking from './promptBeginTracking'; import promptTrackNewSeries from './promptTrackNewSeries'; import promptTrackNewStudy from './promptTrackNewStudy'; @@ -11,6 +11,7 @@ import promptSaveReport from './promptSaveReport'; import promptHydrateStructuredReport from './promptHydrateStructuredReport'; import hydrateStructuredReport from './hydrateStructuredReport'; import { useAppConfig } from '@state'; +import promptLabelAnnotation from './promptLabelAnnotation'; const TrackedMeasurementsContext = React.createContext(); TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext'; @@ -30,7 +31,7 @@ function TrackedMeasurementsContextProvider( const [viewportGrid, viewportGridService] = useViewportGrid(); const { activeViewportId, viewports } = viewportGrid; - const { measurementService, displaySetService } = servicesManager.services; + const { measurementService, displaySetService, customizationService } = servicesManager.services; const machineOptions = Object.assign({}, defaultOptions); machineOptions.actions = Object.assign({}, machineOptions.actions, { @@ -142,6 +143,20 @@ function TrackedMeasurementsContextProvider( extensionManager, appConfig, }), + promptLabelAnnotation: promptLabelAnnotation.bind(null, { + servicesManager, + extensionManager, + }), + }); + machineOptions.guards = Object.assign({}, machineOptions.guards, { + isLabelOnMeasure: (ctx, evt, condMeta) => { + const labelConfig = customizationService.get('measurementLabels'); + return labelConfig?.labelOnMeasure; + }, + isLabelOnMeasureAndShouldKillMachine: (ctx, evt, condMeta) => { + const labelConfig = customizationService.get('measurementLabels'); + return evt.data && evt.data.userResponse === RESPONSE.NO_NEVER && labelConfig?.labelOnMeasure; + }, }); // TODO: IMPROVE diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js index 2b0b65ca1..fce9930d3 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js @@ -31,10 +31,33 @@ const machineConfiguration = { off: { type: 'final', }, + labellingOnly: { + on: { + TRACK_SERIES: [ + { + target: 'promptLabelAnnotation', + actions: ['setPreviousState'], + }, + { + target: 'off', + }, + ], + }, + }, idle: { entry: 'clearContext', on: { - TRACK_SERIES: 'promptBeginTracking', + TRACK_SERIES: [ + { + target: 'promptLabelAnnotation', + cond: 'isLabelOnMeasure', + actions: ['setPreviousState'], + }, + { + target: 'promptBeginTracking', + actions: ['setPreviousState'], + }, + ], // Unused? We may only do PROMPT_HYDRATE_SR now? SET_TRACKED_SERIES: [ { @@ -64,6 +87,10 @@ const machineConfiguration = { actions: ['setTrackedStudyAndSeries', 'setIsDirty'], cond: 'shouldSetStudyAndSeries', }, + { + target: 'labellingOnly', + cond: 'isLabelOnMeasureAndShouldKillMachine', + }, { target: 'off', cond: 'shouldKillMachine', @@ -80,6 +107,11 @@ const machineConfiguration = { tracking: { on: { TRACK_SERIES: [ + { + target: 'promptLabelAnnotation', + cond: 'isLabelOnMeasure', + actions: ['setPreviousState'], + }, { target: 'promptTrackNewStudy', cond: 'isNewStudy', @@ -252,6 +284,36 @@ const machineConfiguration = { }, }, }, + promptLabelAnnotation: { + invoke: { + src: 'promptLabelAnnotation', + onDone: [ + { + target: 'labellingOnly', + cond: 'wasLabellingOnly', + }, + { + target: 'promptBeginTracking', + cond: 'wasIdle', + }, + { + target: 'promptTrackNewStudy', + cond: 'wasTrackingAndIsNewStudy', + }, + { + target: 'promptTrackNewSeries', + cond: 'wasTrackingAndIsNewSeries', + }, + { + target: 'tracking', + cond: 'wasTracking', + }, + { + target: 'off', + }, + ], + }, + }, }, strict: true, }; @@ -337,6 +399,11 @@ const defaultOptions = { prevTrackedSeries: ctx.trackedSeries.slice().filter(ser => ser !== evt.SeriesInstanceUID), trackedSeries: ctx.trackedSeries.slice().filter(ser => ser !== evt.SeriesInstanceUID), })), + setPreviousState: assign((ctx, evt, meta) => { + return { + prevState: meta.state.value, + }; + }), }, guards: { // We set dirty any time we performan an action that: @@ -362,6 +429,30 @@ const defaultOptions = { evt.SeriesInstanceUID === undefined || ctx.trackedSeries.includes(evt.SeriesInstanceUID) ); }, + wasLabellingOnly: (ctx, evt, condMeta) => { + return ctx.prevState === 'labellingOnly'; + }, + wasIdle: (ctx, evt, condMeta) => { + return ctx.prevState === 'idle'; + }, + wasTracking: (ctx, evt, condMeta) => { + return ctx.prevState === 'tracking'; + }, + wasTrackingAndIsNewStudy: (ctx, evt, condMeta) => { + return ( + ctx.prevState === 'tracking' && + !ctx.ignoredSeries.includes(evt.data.SeriesInstanceUID) && + ctx.trackedStudy !== evt.data.StudyInstanceUID + ); + }, + wasTrackingAndIsNewSeries: (ctx, evt, condMeta) => { + return ( + ctx.prevState === 'tracking' && + !ctx.ignoredSeries.includes(evt.data.SeriesInstanceUID) && + !ctx.trackedSeries.includes(evt.data.SeriesInstanceUID) + ); + }, + shouldKillMachine: (ctx, evt) => evt.data && evt.data.userResponse === RESPONSE.NO_NEVER, shouldAddSeries: (ctx, evt) => evt.data && evt.data.userResponse === RESPONSE.ADD_SERIES, shouldSetStudyAndSeries: (ctx, evt) => @@ -397,4 +488,4 @@ const defaultOptions = { }, }; -export { defaultOptions, machineConfiguration }; +export { defaultOptions, machineConfiguration, RESPONSE }; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js index 41fa0baca..acf298146 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js @@ -11,7 +11,9 @@ const RESPONSE = { function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) { const { uiViewportDialogService } = servicesManager.services; - const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt; + // 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); diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptLabelAnnotation.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptLabelAnnotation.js new file mode 100644 index 000000000..b244ccead --- /dev/null +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptLabelAnnotation.js @@ -0,0 +1,33 @@ +function promptLabelAnnotation({ servicesManager, extensionManager }, ctx, evt) { + const { measurementService, customizationService } = servicesManager.services; + const { viewportId, StudyInstanceUID, SeriesInstanceUID, measurementId } = evt; + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.common' + ); + const { showLabelAnnotationPopup } = utilityModule.exports; + return new Promise(async function (resolve) { + const labelConfig = customizationService.get('measurementLabels'); + const measurement = measurementService.getMeasurement(measurementId); + const value = await showLabelAnnotationPopup( + measurement, + servicesManager.services.uiDialogService, + labelConfig + ); + + measurementService.update( + measurementId, + { + ...value, + }, + true + ); + + resolve({ + StudyInstanceUID, + SeriesInstanceUID, + viewportId, + }); + }); +} + +export default promptLabelAnnotation; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js index 8a54598ea..d5e5575c5 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js @@ -11,7 +11,9 @@ const RESPONSE = { function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) { const { UIViewportDialogService } = servicesManager.services; - const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt; + // 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); diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js index 23ea2b11e..2837de7cb 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js @@ -11,7 +11,9 @@ const RESPONSE = { function promptTrackNewStudy({ servicesManager, extensionManager }, ctx, evt) { const { UIViewportDialogService } = servicesManager.services; - const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt; + // 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); diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx index 3605995c6..e0f73d601 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx @@ -29,7 +29,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { const [viewportGrid] = useViewportGrid(); const [measurementChangeTimestamp, setMeasurementsUpdated] = useState(Date.now().toString()); const debouncedMeasurementChangeTimestamp = useDebounce(measurementChangeTimestamp, 200); - const { measurementService, uiDialogService, displaySetService } = servicesManager.services; + const { measurementService, uiDialogService, displaySetService, customizationService } = servicesManager.services; const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements(); const { trackedStudy, trackedSeries } = trackedMeasurements.context; const [displayStudySummary, setDisplayStudySummary] = useState( @@ -134,67 +134,24 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { }; const onMeasurementItemEditHandler = ({ uid, isActive }) => { - const measurement = measurementService.getMeasurement(uid); jumpToImage({ uid, isActive }); - - const onSubmitHandler = ({ action, value }) => { - switch (action.id) { - case 'save': { - measurementService.update( - uid, - { - ...measurement, - ...value, - }, - true - ); - } + const labelConfig = customizationService.get('measurementLabels'); + const measurement = measurementService.getMeasurement(uid); + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.common' + ); + const { showLabelAnnotationPopup } = utilityModule.exports; + showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then( + (val: Map) => { + measurementService.update( + uid, + { + ...val, + }, + true + ); } - uiDialogService.dismiss({ id: 'enter-annotation' }); - }; - - uiDialogService.create({ - id: 'enter-annotation', - centralize: true, - isDraggable: false, - showOverlay: true, - content: Dialog, - contentProps: { - title: 'Annotation', - noCloseButton: true, - value: { label: measurement.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, - }, - }); + ); }; const onMeasurementItemClickHandler = ({ uid, isActive }) => { diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index e8400d4a9..3c55e8f68 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -140,7 +140,9 @@ function TrackedCornerstoneViewport(props) { // Only send the tracked measurements event for the active viewport to avoid // sending it more than once. if (viewportId === activeViewportId) { - const { referenceStudyUID: StudyInstanceUID, referenceSeriesUID: SeriesInstanceUID } = + const { referenceStudyUID: StudyInstanceUID, + referenceSeriesUID: SeriesInstanceUID, + uid: measurementId } = measurement; sendTrackedMeasurementsEvent('SET_DIRTY', { SeriesInstanceUID }); @@ -148,6 +150,7 @@ function TrackedCornerstoneViewport(props) { viewportId, StudyInstanceUID, SeriesInstanceUID, + measurementId }); } }).unsubscribe diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index a04a7d301..6925384fb 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -70,14 +70,28 @@ function modeFactory({ modeConfiguration }) { /** * Lifecycle hooks */ - onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { + onModeEnter: function ({ servicesManager, extensionManager, commandsManager }) { const { measurementService, toolbarService, toolGroupService, customizationService } = servicesManager.services; measurementService.clearMeasurements(); + // customizationService.addModeCustomizations([ + // { + // id: 'measurementLabels', + // labelOnMeasure: true, + // exclusive: true, + // items: [ + // { value: 'Head', label: 'Head' }, + // { value: 'Shoulder', label: 'Shoulder' }, + // { value: 'Knee', label: 'Knee' }, + // { value: 'Toe', label: 'Toe' }, + // ], + // }, + // ]); + // Init Default and SR ToolGroups - initToolGroups(extensionManager, toolGroupService, commandsManager); + initToolGroups(extensionManager, toolGroupService, commandsManager, this.labelConfig); toolbarService.addButtons([...toolbarButtons, ...moreTools]); toolbarService.createButtonSection('primary', [ diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index bb9f0fcd3..293729ccf 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -1,4 +1,10 @@ -function initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, toolGroupId) { +function initDefaultToolGroup( + extensionManager, + toolGroupService, + commandsManager, + toolGroupId, + modeLabelConfig +) { const utilityModule = extensionManager.getModuleEntry( '@ohif/extension-cornerstone.utilityModule.tools' ); @@ -26,18 +32,25 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage { toolName: toolNames.ArrowAnnotate, configuration: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), + getTextCallback: (callback, eventDetails) => { + if (modeLabelConfig) { + callback(' '); + } else { + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }); + } + }, + changeTextCallback: (data, eventDetails, callback) => { + if (modeLabelConfig === undefined) { + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }); + } + }, }, }, { toolName: toolNames.Bidirectional }, @@ -126,7 +139,7 @@ function initSRToolGroup(extensionManager, toolGroupService) { toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } -function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { +function initMPRToolGroup(extensionManager, toolGroupService, commandsManager, modeLabelConfig) { const utilityModule = extensionManager.getModuleEntry( '@ohif/extension-cornerstone.utilityModule.tools' ); @@ -154,18 +167,25 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: toolNames.ArrowAnnotate, configuration: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), + getTextCallback: (callback, eventDetails) => { + if (modeLabelConfig) { + callback(''); + } else { + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }); + } + }, + changeTextCallback: (data, eventDetails, callback) => { + if (modeLabelConfig === undefined) { + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }); + } + }, }, }, { toolName: toolNames.Bidirectional }, @@ -227,10 +247,16 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) { toolGroupService.createToolGroupAndAddTools('volume3d', tools); } -function initToolGroups(extensionManager, toolGroupService, commandsManager) { - initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default'); - initSRToolGroup(extensionManager, toolGroupService); - initMPRToolGroup(extensionManager, toolGroupService, commandsManager); +function initToolGroups(extensionManager, toolGroupService, commandsManager, modeLabelConfig) { + initDefaultToolGroup( + extensionManager, + toolGroupService, + commandsManager, + 'default', + modeLabelConfig + ); + initSRToolGroup(extensionManager, toolGroupService, commandsManager); + initMPRToolGroup(extensionManager, toolGroupService, commandsManager, modeLabelConfig); initVolume3DToolGroup(extensionManager, toolGroupService); } diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index e3cbb1967..6f4fb65f5 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -44,8 +44,12 @@ function modeFactory({ modeConfiguration }) { * Lifecycle hooks */ onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { - const { toolbarService, toolGroupService, hangingProtocolService, displaySetService } = - servicesManager.services; + const { + toolbarService, + toolGroupService, + hangingProtocolService, + displaySetService, + } = servicesManager.services; const utilityModule = extensionManager.getModuleEntry( '@ohif/extension-cornerstone.utilityModule.tools' diff --git a/modes/tmtv/src/initToolGroups.js b/modes/tmtv/src/initToolGroups.js index 6585de299..75cedca46 100644 --- a/modes/tmtv/src/initToolGroups.js +++ b/modes/tmtv/src/initToolGroups.js @@ -7,7 +7,7 @@ export const toolGroupIds = { // MPR: 'mpr', }; -function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { +function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager, modeLabelConfig) { const tools = { active: [ { @@ -30,18 +30,24 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { toolName: toolNames.ArrowAnnotate, configuration: { getTextCallback: (callback, eventDetails) => { - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }); + if (modeLabelConfig) { + callback(' '); + } else { + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }); + } + }, + changeTextCallback: (data, eventDetails, callback) => { + if (modeLabelConfig === undefined) { + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }); + } }, - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), }, }, { toolName: toolNames.Bidirectional }, @@ -102,8 +108,8 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { toolGroupService.createToolGroupAndAddTools(toolGroupIds.MIP, mipTools); } -function initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { - _initToolGroups(toolNames, Enums, toolGroupService, commandsManager); +function initToolGroups(toolNames, Enums, toolGroupService, commandsManager, modeLabelConfig) { + _initToolGroups(toolNames, Enums, toolGroupService, commandsManager, modeLabelConfig); } export default initToolGroups; diff --git a/platform/docs/docs/platform/modes/index.md b/platform/docs/docs/platform/modes/index.md index e62c8c931..35678edc7 100644 --- a/platform/docs/docs/platform/modes/index.md +++ b/platform/docs/docs/platform/modes/index.md @@ -79,7 +79,7 @@ function modeFactory() { extensions: extensionDependencies, hangingProtocol: [], sopClassHandlers: [], - hotkeys: [], + hotkeys: [] }; } diff --git a/platform/ui/src/components/Labelling/LabellingFlow.tsx b/platform/ui/src/components/Labelling/LabellingFlow.tsx new file mode 100644 index 000000000..8d1dfe75f --- /dev/null +++ b/platform/ui/src/components/Labelling/LabellingFlow.tsx @@ -0,0 +1,104 @@ +import SelectTree from '../SelectTree'; +import React, { Component } from 'react'; +import LabellingTransition from './LabellingTransition'; +import cloneDeep from 'lodash.clonedeep'; + +interface PropType { + labellingDoneCallback: (label: string) => void; + measurementData: any; + labelData: any; + exclusive: boolean; + componentClassName: any; +} + +interface StateType { + location: Location; + label: string; + componentClassName: any; + confirmationState: boolean; + displayComponent: boolean; +} + +export interface LabelInfo { + label: string; + value: string; +} + +class LabellingFlow extends Component { + currentItems: Array = []; + state: StateType; + mainElement; + + constructor(props) { + super(props); + const { label } = props.measurementData; + const className = props.componentClassName; + + this.state = { + location, + label, + componentClassName: className, + confirmationState: false, + displayComponent: true, + }; + this.mainElement = React.createRef(); + } + + render() { + if (this.props.labelData) { + this.currentItems = cloneDeep(this.props.labelData); + } + + const className = Object.assign({}, this.state.componentClassName); + + return ( + + <> +
+ {this.labellingStateFragment()} +
+ +
+ ); + } + + closePopup = () => { + this.setState({ + displayComponent: false, + }); + + setTimeout(() => { + this.setState({ + displayComponent: false, + }); + }, 2000); + }; + + selectTreeSelectCalback = (event, itemSelected) => { + const label = itemSelected.value; + this.closePopup(); + return this.props.labellingDoneCallback(label); + }; + + labellingStateFragment = () => { + return ( + + ); + }; +} + +export default LabellingFlow; diff --git a/platform/ui/src/components/Labelling/LabellingTransition.tsx b/platform/ui/src/components/Labelling/LabellingTransition.tsx new file mode 100644 index 000000000..359282627 --- /dev/null +++ b/platform/ui/src/components/Labelling/LabellingTransition.tsx @@ -0,0 +1,28 @@ +import React, { Component } from 'react'; +import { CSSTransition } from 'react-transition-group'; + +const transitionDuration = 0; +const transitionClassName = 'labelling'; +const transitionOnAppear = true; + +interface PropType { + children: React.ReactNode; + displayComponent: boolean; + onTransitionExit: any; +} + +export default class LabellingTransition extends Component { + render() { + return ( + + {this.props.children} + + ); + } +} diff --git a/platform/ui/src/components/Labelling/index.js b/platform/ui/src/components/Labelling/index.js new file mode 100644 index 000000000..77a5fe795 --- /dev/null +++ b/platform/ui/src/components/Labelling/index.js @@ -0,0 +1,2 @@ +import LabellingFlow from './LabellingFlow'; +export default LabellingFlow; diff --git a/platform/ui/src/components/SelectTree/InputRadio.tsx b/platform/ui/src/components/SelectTree/InputRadio.tsx new file mode 100644 index 000000000..85ee4c7e4 --- /dev/null +++ b/platform/ui/src/components/SelectTree/InputRadio.tsx @@ -0,0 +1,41 @@ +import { Component } from 'react'; +import React from 'react'; + +interface PropsType { + value: string; + label: string; + itemData: { label: string; value: string }; + id: string; + onSelected: Function; + index: number; + selectTree: any; +} + +export default class InputRadio extends Component { + render() { + const { focusedIndex } = this.props.selectTree.state; + const isFocused = this.props.index === focusedIndex; + + return ( + + ); + } + + onSelected = evt => { + this.props.onSelected(evt, this.props.itemData); + }; +} diff --git a/platform/ui/src/components/SelectTree/SelectTree.tsx b/platform/ui/src/components/SelectTree/SelectTree.tsx new file mode 100644 index 000000000..cd92244af --- /dev/null +++ b/platform/ui/src/components/SelectTree/SelectTree.tsx @@ -0,0 +1,253 @@ +import React, { Component } from 'react'; +import InputRadio from './InputRadio'; +import SelectTreeBreadcrumb from './SelectTreeBreadcrumb'; +import cloneDeep from 'lodash.clonedeep'; +import Icon from '../Icon'; +import Button, { ButtonEnums } from '../Button'; +import { LabelInfo } from '../Labelling/LabellingFlow'; + +interface PropType { + autoFocus: boolean; + searchEnabled: boolean; + selectTreeFirstTitle: string; + items: Array; + onSelected: Function; + exclusive: boolean; + closePopup: Function; + label: string; + columns: number; +} + +interface StateProps { + searchTerm: string | null; + currentNode: any; + value: string | null; + focusedIndex: number; +} + +export class SelectTree extends Component { + state: StateProps; + static defaultProps = { + searchEnabled: true, + autoFocus: true, + selectTreeFirstTitle: 'First Level itens', + items: [], + }; + + constructor(props) { + super(props); + + this.state = { + searchTerm: this.props.items.length > 0 ? null : this.props.label, + currentNode: null, + value: null, + focuseIndex: 0, + }; + } + + render() { + const treeItems = this.getTreeItems(); + + return ( +
+
+ {this.headerItem()} + + {this.props.items.length > 0 && ( +
+ {this.state.currentNode && ( + + )} +
+
{treeItems}
+
+
+ )} +
+
+ ); + } + + isLeafSelected = item => item && !Array.isArray(item.items); + + filterItems() { + const filteredItems = []; + const rawItems = cloneDeep(this.props.items); + rawItems.forEach(item => { + if (Array.isArray(item.items)) { + item.items.forEach(item => { + const label = item.label.toLowerCase(); + const searchTerm = this.state.searchTerm.toLowerCase(); + if (label.indexOf(searchTerm) !== -1) { + filteredItems.push(item); + } + }); + } else { + const label = item.label.toLowerCase(); + const searchTerm = this.state.searchTerm.toLowerCase(); + if (label.indexOf(searchTerm) !== -1) { + filteredItems.push(item); + } + } + }); + return filteredItems; + } + + getTreeItems() { + const storageKey = 'SelectTree'; + let treeItems: Array; + + if (this.state.searchTerm) { + const filterItems = this.filterItems(); + if ( + this.props.exclusive === false && + filterItems.find(item => item.label === this.state.searchTerm) === undefined + ) { + treeItems = [ + { label: this.state.searchTerm, value: this.state.searchTerm }, + ...filterItems, + ]; + } else { + treeItems = filterItems; + } + } else if (this.state.currentNode) { + treeItems = cloneDeep(this.state.currentNode.items); + } else { + treeItems = cloneDeep(this.props.items); + } + + return treeItems.map((item, index) => { + const itemKey = index; + return ( + + ); + }); + } + + handleKeyDown = event => { + const { key } = event; + const { focusedIndex } = this.state; + const treeItems = this.getTreeItems(); + + if (key === 'ArrowUp') { + this.setState(prevState => ({ + focusedIndex: + prevState.focusedIndex > 0 ? prevState.focusedIndex - 1 : treeItems.length - 1, + })); + } else if (key === 'ArrowDown') { + this.setState(prevState => ({ + focusedIndex: + prevState.focusedIndex < treeItems.length - 1 ? prevState.focusedIndex + 1 : 0, + })); + } else if (key === 'Enter') { + const selectedItem = this.state.searchTerm + ? { label: this.state.searchTerm, value: this.state.searchTerm } + : treeItems[focusedIndex].props.itemData; + this.onSelected(event, selectedItem); + } + }; + + onSubmitHandler = evt => { + this.props.onSelected(evt, { + label: this.state.searchTerm, + value: this.state.searchTerm, + }); + }; + + 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 && ( +
+ +
+ )} + 0 ? 'Search labels' : 'Enter label'} + autoFocus={this.props.autoFocus} + onChange={this.searchLocations} + value={this.state.searchTerm ? this.state.searchTerm : ''} + onKeyDown={this.handleKeyDown} + /> +
+ )} + {this.props.items.length === 0 && ( +
+ +
+ )} +
+ ); + }; + + searchLocations = evt => { + this.setState({ + currentNode: null, + searchTerm: evt.currentTarget.value, + }); + }; + + onSelected = (event, item) => { + if (this.isLeafSelected(item)) { + this.setState({ + searchTerm: null, + currentNode: null, + value: null, + }); + } else { + this.setState({ + currentNode: item, + }); + } + return this.props.onSelected(event, item); + }; + + onBreadcrumbSelected = () => { + this.setState({ + currentNode: null, + }); + }; +} diff --git a/platform/ui/src/components/SelectTree/SelectTreeBreadcrumb.tsx b/platform/ui/src/components/SelectTree/SelectTreeBreadcrumb.tsx new file mode 100644 index 000000000..ee19ba6a6 --- /dev/null +++ b/platform/ui/src/components/SelectTree/SelectTreeBreadcrumb.tsx @@ -0,0 +1,36 @@ +import { Component } from 'react'; +import React from 'react'; +import Icon from '../Icon'; + +interface PropType { + value: string; + label: string; + onSelected: (event: any) => void; +} + +export default class SelectTreeBreadcrumb extends Component { + render() { + return ( +
+ +
+ ); + } +} diff --git a/platform/ui/src/components/SelectTree/index.js b/platform/ui/src/components/SelectTree/index.js new file mode 100644 index 000000000..a8b1f6f40 --- /dev/null +++ b/platform/ui/src/components/SelectTree/index.js @@ -0,0 +1,2 @@ +import { SelectTree } from './SelectTree'; +export default SelectTree; diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index feda4c36a..0dc9438da 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -80,6 +80,7 @@ import PanelSection from './PanelSection'; import AdvancedToolbox from './AdvancedToolbox'; import InputDoubleRange from './InputDoubleRange'; import LegacyButtonGroup from './LegacyButtonGroup'; +import LabellingFlow from './Labelling'; import SwitchButton, { SwitchLabelLocation } from './SwitchButton'; import * as AllInOneMenu from './AllInOneMenu'; import ViewportActionArrows from './ViewportActionArrows'; @@ -89,7 +90,6 @@ import { ToolSettings } from './AdvancedToolbox'; import { Toolbox } from './Toolbox'; import InvestigationalUseDialog from './InvestigationalUseDialog'; import MeasurementItem from './MeasurementTable/MeasurementItem'; - import LayoutPreset from './LayoutPreset'; export { @@ -182,6 +182,7 @@ export { ViewportPane, ViewportOverlay, WindowLevelMenuItem, + LabellingFlow, HeaderPatientInfo, LayoutPreset, LegacySplitButton, diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index b275d67a6..704f27302 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -121,6 +121,7 @@ export { WindowLevelMenuItem, ImageScrollbar, ViewportOverlay, + LabellingFlow, HeaderPatientInfo, ToolSettings, Toolbox,