From 401a2e38c8577c74811b0230adff7ea50f9de328 Mon Sep 17 00:00:00 2001 From: James Petts Date: Wed, 5 Aug 2020 13:56:02 +0100 Subject: [PATCH] [OHIF-193 + OHIF-313] (#1955) * create context menu * OHIF-193: Add context menu integration with measurement service * OHIF-193: Update comments * OHIF-CR Update: Add requested updates * CR Update: Update measurement service remove function * CR Update: Update casing * CR Updates * Update cornerstone measurments when the label is edited in the measurement service. * Implement click outside behavior * Address reviewer comments. Co-authored-by: Rodrigo Antinarelli Co-authored-by: igoroctaviano --- extensions/cornerstone/src/index.js | 4 +- extensions/cornerstone/src/init.js | 354 ++++++++++++++---- .../utils/getCornerstoneMeasurementById.js | 31 ++ .../ArrowAnnotate.js | 7 +- .../Bidirectional.js | 9 +- .../EllipticalRoi.js | 9 +- .../measurementServiceMappings/Length.js | 31 +- .../measurementServiceMappingsFactory.test.js | 4 +- .../utils/getHandlesFromPoints.js | 9 + .../PanelMeasurementTableTracking/index.js | 19 +- .../MeasurementService/MeasurementService.js | 97 +++-- .../src/services/UIDialogService/index.js | 2 + platform/ui/index.js | 2 + .../components/ContextMenu/ContextMenu.jsx | 34 ++ .../components/ContextMenu/ContextMenu.mdx | 49 +++ .../ui/src/components/ContextMenu/index.js | 1 + .../ContextMenuMeasurements.jsx | 44 +++ .../ContextMenuMeasurements/index.js | 1 + platform/ui/src/components/index.js | 4 + .../src/contextProviders/DialogProvider.jsx | 60 ++- 20 files changed, 575 insertions(+), 196 deletions(-) create mode 100644 extensions/cornerstone/src/utils/getCornerstoneMeasurementById.js create mode 100644 platform/ui/src/components/ContextMenu/ContextMenu.jsx create mode 100644 platform/ui/src/components/ContextMenu/ContextMenu.mdx create mode 100644 platform/ui/src/components/ContextMenu/index.js create mode 100644 platform/ui/src/components/ContextMenuMeasurements/ContextMenuMeasurements.jsx create mode 100644 platform/ui/src/components/ContextMenuMeasurements/index.js diff --git a/extensions/cornerstone/src/index.js b/extensions/cornerstone/src/index.js index 4885d0591..0d0ec725f 100644 --- a/extensions/cornerstone/src/index.js +++ b/extensions/cornerstone/src/index.js @@ -30,8 +30,8 @@ export default { * @param {object} [configuration={}] * @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools` */ - preRegistration({ servicesManager, configuration = {} }) { - init({ servicesManager, configuration }); + preRegistration({ servicesManager, commandsManager, configuration = {} }) { + init({ servicesManager, commandsManager, configuration }); }, getViewportModule({ commandsManager }) { const ExtendedOHIFCornerstoneViewport = props => { diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index 2d1470bdc..a56a5c4c6 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -1,32 +1,239 @@ import React from 'react'; import OHIF from '@ohif/core'; -import { Dialog, Input } from '@ohif/ui'; -import cornerstone from 'cornerstone-core'; +import { Input, Dialog, ContextMenuMeasurements } from '@ohif/ui'; +import cs from 'cornerstone-core'; import csTools from 'cornerstone-tools'; import merge from 'lodash.merge'; import initCornerstoneTools from './initCornerstoneTools.js'; -import cornerstoneTools from 'cornerstone-tools'; -import initWADOImageLoader from './initWADOImageLoader.js'; +import './initWADOImageLoader.js'; +import getCornerstoneMeasurementById from './utils/getCornerstoneMeasurementById'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; -// import { setEnabledElement } from './state'; +// TODO -> Global "context menu open state", or lots of expensive searches on drag? + +let CONTEXT_MENU_OPEN = false; + +const { globalImageIdSpecificToolStateManager } = csTools; + +const TOOL_TYPES_WITH_CONTEXT_MENU = [ + 'Angle', + 'ArrowAnnotate', + 'Bidirectional', + 'Length', + 'FreehandMouse', + 'EllipticalRoi', + 'CircleRoi', + 'RectangleRoi', +]; + +const _refreshViewports = () => + cs.getEnabledElements().forEach(({ element }) => cs.updateImage(element)); + /** * * @param {Object} servicesManager * @param {Object} configuration * @param {Object|Array} configuration.csToolsConfig */ -export default function init({ servicesManager, configuration }) { +export default function init({ + servicesManager, + commandsManager, + configuration, +}) { const { UIDialogService, MeasurementService, DisplaySetService, } = servicesManager.services; - const callInputDialog = (data, event, callback) => { + /* Measurement Service */ + const measurementServiceSource = _connectToolsToMeasurementService( + MeasurementService, + DisplaySetService + ); + + const onRightClick = event => { + if (!UIDialogService) { + console.warn('Unable to show dialog; no UI Dialog Service available.'); + return; + } + + const onGetMenuItems = defaultMenuItems => { + const { element, currentPoints } = event.detail; + const nearbyToolData = commandsManager.runCommand('getNearbyToolData', { + element, + canvasCoordinates: currentPoints.canvas, + availableToolTypes: TOOL_TYPES_WITH_CONTEXT_MENU, + }); + + let menuItems = []; + if (nearbyToolData) { + defaultMenuItems.forEach(item => { + item.value = nearbyToolData; + menuItems.push(item); + }); + } + + return menuItems; + }; + + CONTEXT_MENU_OPEN = true; + + UIDialogService.dismiss({ id: 'context-menu' }); + UIDialogService.create({ + id: 'context-menu', + isDraggable: false, + preservePosition: false, + defaultPosition: _getDefaultPosition(event.detail), + content: ContextMenuMeasurements, + onClickOutside: () => { + UIDialogService.dismiss({ id: 'context-menu' }); + CONTEXT_MENU_OPEN = false; + }, + contentProps: { + onGetMenuItems, + eventData: event.detail, + onDelete: item => { + const { tool: measurementData, toolType } = item.value; + measurementServiceSource.remove(measurementData.id); + _refreshViewports(); + CONTEXT_MENU_OPEN = false; + }, + onClose: () => { + CONTEXT_MENU_OPEN = false; + UIDialogService.dismiss({ id: 'context-menu' }); + }, + onSetLabel: item => { + const { tool: measurementData } = item.value; + + const measurement = MeasurementService.getMeasurement( + measurementData.id + ); + + callInputDialog( + measurement, + (label, actionId) => { + if (actionId === 'cancel') { + return; + } + + const updatedMeasurement = Object.assign({}, measurement, { + label, + }); + + MeasurementService.update( + updatedMeasurement.id, + updatedMeasurement, + true + ); + }, + false + ); + + CONTEXT_MENU_OPEN = false; + }, + }, + }); + }; + + const onTouchPress = event => { + if (!UIDialogService) { + console.warn('Unable to show dialog; no UI Dialog Service available.'); + return; + } + + UIDialogService.create({ + eventData: event.detail, + content: ContextMenuMeasurements, + contentProps: { isTouchEvent: true }, + }); + }; + + const resetContextMenu = () => { + if (!UIDialogService) { + console.warn('Unable to show dialog; no UI Dialog Service available.'); + return; + } + + CONTEXT_MENU_OPEN = false; + + UIDialogService.dismiss({ id: 'context-menu' }); + }; + + /* + * Because click gives us the native "mouse up", buttons will always be `0` + * Need to fallback to event.which; + * + */ + const contextMenuHandleClick = evt => { + const mouseUpEvent = evt.detail.event; + const isRightClick = mouseUpEvent.which === 3; + + const clickMethodHandler = isRightClick ? onRightClick : resetContextMenu; + clickMethodHandler(evt); + }; + + const cancelContextMenuIfOpen = evt => { + if (CONTEXT_MENU_OPEN) { + resetContextMenu(); + } + }; + + function elementEnabledHandler(evt) { + const element = evt.detail.element; + element.addEventListener(csTools.EVENTS.TOUCH_PRESS, onTouchPress); + element.addEventListener( + csTools.EVENTS.MOUSE_CLICK, + contextMenuHandleClick + ); + element.addEventListener(cs.EVENTS.NEW_IMAGE, cancelContextMenuIfOpen); + } + + function elementDisabledHandler(evt) { + const element = evt.detail.element; + element.removeEventListener(csTools.EVENTS.TOUCH_PRESS, onTouchPress); + element.removeEventListener( + csTools.EVENTS.MOUSE_CLICK, + contextMenuHandleClick + ); + element.removeEventListener(cs.EVENTS.NEW_IMAGE, cancelContextMenuIfOpen); + } + + /** + * + * @param {*} data + * @param {*} event + * @param {*} callback + * @param {*} isArrowAnnotateInputDialog + */ + const callInputDialog = ( + data, + callback, + isArrowAnnotateInputDialog = true + ) => { + const dialogId = 'enter-annotation'; + const label = data + ? isArrowAnnotateInputDialog + ? data.text + : data.label + : ''; + + const onSubmitHandler = ({ action, value }) => { + switch (action.id) { + case 'save': + callback(value.label, action.id); + break; + case 'cancel': + callback('', action.id); + break; + } + UIDialogService.dismiss({ id: dialogId }); + }; + if (UIDialogService) { - let dialogId = UIDialogService.create({ + UIDialogService.create({ + id: dialogId, centralize: true, isDraggable: false, content: Dialog, @@ -34,34 +241,26 @@ export default function init({ servicesManager, configuration }) { showOverlay: true, contentProps: { title: 'Enter your annotation', - value: { label: data ? data.text : '' }, + value: { label }, noCloseButton: true, onClose: () => UIDialogService.dismiss({ id: dialogId }), actions: [ { id: 'cancel', text: 'Cancel', type: 'secondary' }, { id: 'save', text: 'Save', type: 'primary' }, ], - onSubmit: ({ action, value }) => { - switch (action.id) { - case 'save': - callback(value.label); - break; - case 'cancel': - callback(); - break; - } - UIDialogService.dismiss({ id: dialogId }); - }, + onSubmit: onSubmitHandler, 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 (
- callInputDialog(null, eventDetails, callback), + callInputDialog(null, callback), changeTextCallback: (data, eventDetails, callback) => - callInputDialog(data, eventDetails, callback), + callInputDialog(data, callback), allowEmptyLabel: true, }, }, @@ -229,6 +422,12 @@ export default function init({ servicesManager, configuration }) { csTools.setToolActive('PanMultiTouch', { pointers: 2 }); // TODO: Better error if no options csTools.setToolActive('ZoomTouchPinch', {}); csTools.setToolEnabled('Overlay', {}); + + cs.events.addEventListener(cs.EVENTS.ELEMENT_ENABLED, elementEnabledHandler); + cs.events.addEventListener( + cs.EVENTS.ELEMENT_DISABLED, + elementDisabledHandler + ); } const _initMeasurementService = (MeasurementService, DisplaySetService) => { @@ -293,12 +492,11 @@ const _connectToolsToMeasurementService = ( csToolsVer4MeasurementSource ); const { addOrUpdate, remove } = csToolsVer4MeasurementSource; - const elementEnabledEvt = cornerstone.EVENTS.ELEMENT_ENABLED; + const elementEnabledEvt = cs.EVENTS.ELEMENT_ENABLED; /* Measurement Service Events */ - cornerstone.events.addEventListener(elementEnabledEvt, evt => { + cs.events.addEventListener(elementEnabledEvt, evt => { // TODO: Debounced update of measurements that are modified - function addMeasurement(csToolsEvent) { console.log('CSTOOLS::addOrUpdate', csToolsEvent, csToolsEvent.detail); @@ -322,13 +520,13 @@ const _connectToolsToMeasurementService = ( if (!csToolsEvent.detail.measurementData.id) { return; } + const evtDetail = csToolsEvent.detail; const { toolName, toolType, measurementData } = evtDetail; const csToolName = toolName || measurementData.toolType || toolType; evtDetail.id = csToolsEvent.detail.measurementData.id; addOrUpdate(csToolName, evtDetail); - // } catch (error) { console.warn('Failed to update measurement:', error); } @@ -351,14 +549,42 @@ const _connectToolsToMeasurementService = ( } } - const { MEASUREMENTS_CLEARED } = MeasurementService.EVENTS; + const { + MEASUREMENTS_CLEARED, + MEASUREMENT_UPDATED, + } = MeasurementService.EVENTS; MeasurementService.subscribe(MEASUREMENTS_CLEARED, () => { - cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState( - {} - ); + globalImageIdSpecificToolStateManager.restoreToolState({}); + _refreshViewports(); }); + MeasurementService.subscribe( + MEASUREMENT_UPDATED, + ({ source, measurement, notYetUpdatedAtSource }) => { + const { id, label } = measurement; + + if ( + source.name == 'CornerstoneTools' && + notYetUpdatedAtSource === false + ) { + // This event was fired by cornerstone telling the measurement service to sync. Already in sync. + return; + } + const cornerstoneMeasurement = getCornerstoneMeasurementById(id); + + if (cornerstoneMeasurement) { + cornerstoneMeasurement.label = label; + if (cornerstoneMeasurement.hasOwnProperty('text')) { + // Deal with the weird case of ArrowAnnotate. + cornerstoneMeasurement.text = label; + } + + _refreshViewports(); + } + } + ); + const enabledElement = evt.detail.element; const completedEvt = csTools.EVENTS.MEASUREMENT_COMPLETED; const updatedEvt = csTools.EVENTS.MEASUREMENT_MODIFIED; @@ -368,25 +594,17 @@ const _connectToolsToMeasurementService = ( enabledElement.addEventListener(updatedEvt, updateMeasurement); enabledElement.addEventListener(removedEvt, removeMeasurement); }); + + return csToolsVer4MeasurementSource; }; const _connectMeasurementServiceToTools = ( MeasurementService, measurementSource ) => { - const { - MEASUREMENTS_CLEARED, - MEASUREMENT_REMOVED, - } = MeasurementService.EVENTS; + const { MEASUREMENT_REMOVED } = MeasurementService.EVENTS; const sourceId = measurementSource.id; - MeasurementService.subscribe(MEASUREMENTS_CLEARED, () => { - cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState({}); - cornerstone.getEnabledElements().forEach(enabledElement => { - cornerstone.updateImage(enabledElement.element); - }); - }); - // TODO: This is an unsafe delete // Cornerstone-tools should probably expose a more generic "delete by id" // And have toolState managers expose a method to find any of their toolState by ID @@ -401,7 +619,7 @@ const _connectMeasurementServiceToTools = ( MEASUREMENT_REMOVED, ({ source, measurement: removedMeasurementId }) => { // THIS POINTS TO ORIGINAL; Not a copy - const imageIdSpecificToolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState(); + const imageIdSpecificToolState = globalImageIdSpecificToolStateManager.saveToolState(); // ImageId --> Object.keys(imageIdSpecificToolState).forEach(imageId => { @@ -425,37 +643,7 @@ const _connectMeasurementServiceToTools = ( ); }; -// const { -// MEASUREMENT_ADDED, -// MEASUREMENT_UPDATED, -// } = MeasurementService.EVENTS; - -// MeasurementService.subscribe( -// MEASUREMENT_ADDED, -// ({ source, measurement }) => { -// if (![sourceId].includes(source.id)) { -// const annotation = getAnnotation('Length', measurement.id); - -// console.log( -// 'Measurement Service [Cornerstone]: Measurement added', -// measurement -// ); -// console.log('Mapped annotation:', annotation); -// } -// } -// ); - -// MeasurementService.subscribe( -// MEASUREMENT_UPDATED, -// ({ source, measurement }) => { -// if (![sourceId].includes(source.id)) { -// const annotation = getAnnotation('Length', measurement.id); - -// console.log( -// 'Measurement Service [Cornerstone]: Measurement updated', -// measurement -// ); -// console.log('Mapped annotation:', annotation); -// } -// } -// ); +const _getDefaultPosition = event => ({ + x: (event && event.currentPoints.client.x) || 0, + y: (event && event.currentPoints.client.y) || 0, +}); diff --git a/extensions/cornerstone/src/utils/getCornerstoneMeasurementById.js b/extensions/cornerstone/src/utils/getCornerstoneMeasurementById.js new file mode 100644 index 000000000..61f3003a0 --- /dev/null +++ b/extensions/cornerstone/src/utils/getCornerstoneMeasurementById.js @@ -0,0 +1,31 @@ +import cornerstoneTools from 'cornerstone-tools'; + +const { globalImageIdSpecificToolStateManager } = cornerstoneTools; + +export default function getCornerstoneMeasurementById(id) { + const globalToolState = globalImageIdSpecificToolStateManager.saveToolState(); + + const imageIds = Object.keys(globalToolState); + + for (let i = 0; i < imageIds.length; i++) { + const imageId = imageIds[i]; + const imageIdSpecificToolState = globalToolState[imageId]; + + const toolTypes = Object.keys(imageIdSpecificToolState); + + for (let j = 0; j < toolTypes.length; j++) { + const toolType = toolTypes[j]; + const toolData = imageIdSpecificToolState[toolType].data; + + if (toolData) { + for (let k = 0; k < toolData.length; k++) { + const toolDataK = toolData[k]; + + if (toolDataK.id === id) { + return toolDataK; + } + } + } + } + } +} diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js index 8a081e2e4..89b4184f7 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js @@ -1,11 +1,10 @@ import SUPPORTED_TOOLS from './constants/supportedTools'; import getPointsFromHandles from './utils/getPointsFromHandles'; +import getHandlesFromPoints from './utils/getHandlesFromPoints'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; const ArrowAnnotate = { - toAnnotation: (measurement, definition) => { - // TODO -> Implement when this is needed. - }, + toAnnotation: (measurement, definition) => {}, toMeasurement: ( csToolsAnnotation, DisplaySetService, @@ -40,7 +39,7 @@ const ArrowAnnotate = { return { id: measurementData.id, - SOPInstanceUID: SOPInstanceUID, + SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, referenceStudyUID: StudyInstanceUID, diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js index e31cc7849..34eeb21ff 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js @@ -1,10 +1,9 @@ import SUPPORTED_TOOLS from './constants/supportedTools'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; +import getHandlesFromPoints from './utils/getHandlesFromPoints'; const Bidirectional = { - toAnnotation: (measurement, definition) => { - // TODO -> Implement when this is needed. - }, + toAnnotation: (measurement, definition) => {}, toMeasurement: ( csToolsAnnotation, DisplaySetService, @@ -41,12 +40,12 @@ const Bidirectional = { return { id: measurementData.id, - SOPInstanceUID: SOPInstanceUID, + SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, referenceStudyUID: StudyInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID, - label: measurementData.text, + label: measurementData.label, description: measurementData.description, unit: measurementData.unit, shortestDiameter: measurementData.shortestDiameter, diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js index 19c2803b5..5ecf0b19a 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js @@ -1,10 +1,9 @@ import SUPPORTED_TOOLS from './constants/supportedTools'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; +import getHandlesFromPoints from './utils/getHandlesFromPoints'; const EllipticalRoi = { - toAnnotation: (measurement, definition) => { - // TODO -> Implement when this is needed. - }, + toAnnotation: (measurement, definition) => {}, toMeasurement: ( csToolsAnnotation, DisplaySetService, @@ -63,12 +62,12 @@ const EllipticalRoi = { return { id: measurementData.id, - SOPInstanceUID: SOPInstanceUID, + SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, referenceStudyUID: StudyInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID, - label: measurementData.text, + label: measurementData.label, description: measurementData.description, unit: measurementData.unit, area: diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js index 777e98a0a..273e4e44b 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js @@ -4,32 +4,7 @@ import getPointsFromHandles from './utils/getPointsFromHandles'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; const Length = { - toAnnotation: (measurement, definition) => { - const { - id, - label, - description, - points, - unit, - SOPInstanceUID, - FrameOfReferenceUID, - referenceSeriesUID, - } = measurement; - - return { - toolName: definition, - measurementData: { - sopInstanceUid: SOPInstanceUID, - frameOfReferenceUID: FrameOfReferenceUID, - SeriesInstanceUID: referenceSeriesUID, - unit, - text: label, - description, - handles: getHandlesFromPoints(points), - id, - }, - }; - }, + toAnnotation: (measurement, definition) => {}, /** * Maps cornerstone annotation event data to measurement service format. @@ -68,12 +43,12 @@ const Length = { return { id: measurementData.id, - SOPInstanceUID: SOPInstanceUID, + SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, referenceStudyUID: StudyInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID, - label: measurementData.text, + label: measurementData.label, description: measurementData.description, unit: measurementData.unit, length: measurementData.length, diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.test.js b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.test.js index 067782d0e..f990e5258 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.test.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.test.js @@ -44,8 +44,8 @@ describe('measurementServiceMappings.js', () => { toolName: definition, measurementData: { _measurementServiceId: 1, - sopInstanceUid: '123', - frameOfReferenceUID: '123', + SOPInstanceUID: '123', + FrameOfReferenceUID: '123', SeriesInstanceUID: '123', handles, text: 'Test', diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getHandlesFromPoints.js b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getHandlesFromPoints.js index 1440405bc..6d381941b 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getHandlesFromPoints.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getHandlesFromPoints.js @@ -1,4 +1,13 @@ export default function getHandlesFromPoints(points) { + if (points.longAxis && points.shortAxis) { + const handles = {}; + handles.start = points.longAxis[0]; + handles.end = points.longAxis[1]; + handles.perpendicularStart = points.longAxis[0]; + handles.perpendicularEnd = points.longAxis[1]; + return handles; + } + return points .map((p, i) => (i % 10 === 0 ? { start: p } : { end: p })) .reduce((obj, item) => Object.assign(obj, { ...item }), {}); diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js index 614231ff4..6b22285fd 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js @@ -162,19 +162,24 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { const onMeasurementItemEditHandler = ({ id }) => { const measurement = MeasurementService.getMeasurement(id); - let dialogId; const onSubmitHandler = ({ action, value }) => { switch (action.id) { case 'save': { - MeasurementService.update(id, { - ...measurement, - ...value, - }); + MeasurementService.update( + id, + { + ...measurement, + ...value, + }, + true + ); } } - UIDialogService.dismiss({ id: dialogId }); + UIDialogService.dismiss({ id: 'enter-annotation' }); }; - dialogId = UIDialogService.create({ + + UIDialogService.create({ + id: 'enter-annotation', centralize: true, isDraggable: false, useLastPosition: false, diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js index 82baa8e8c..0618cef1f 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.js @@ -16,7 +16,7 @@ import pubSubServiceInterface from '../_shared/pubSubServiceInterface'; * * @typedef {Object} Measurement * @property {number} id - - * @property {string} sopInstanceUid - + * @property {string} SOPInstanceUID - * @property {string} FrameOfReferenceUID - * @property {string} referenceSeriesUID - * @property {string} label - @@ -50,6 +50,7 @@ const MEASUREMENT_SCHEMA_KEYS = [ const EVENTS = { MEASUREMENT_UPDATED: 'event::measurement_updated', + INTERNAL_MEASUREMENT_UPDATED: 'event:internal_measurement_updated', MEASUREMENT_ADDED: 'event::measurement_added', MEASUREMENT_REMOVED: 'event::measurement_removed', MEASUREMENTS_CLEARED: 'event::measurements_cleared', @@ -146,7 +147,7 @@ class MeasurementService { return this.addOrUpdate(source, definition, measurement); }; source.remove = id => { - return this.remove(source, id); + return this.remove(id, source); }; source.getAnnotation = (definition, measurementId) => { return this.getAnnotation(source, definition, measurementId); @@ -216,11 +217,6 @@ class MeasurementService { return; } - if (!toSourceSchema) { - log.warn('Source mapping function not provided. Exiting early.'); - return; - } - if (!toMeasurementSchema) { log.warn('Measurement mapping function not provided. Exiting early.'); return; @@ -269,9 +265,9 @@ class MeasurementService { measurementId, definition ); + const measurement = this.getMeasurement(measurementId); if (mapping) return mapping.toSourceSchema(measurement, definition); - const measurement = this.getMeasurement(measurementId); const matchingMapping = this._getMatchingMapping( source, definition, @@ -285,21 +281,28 @@ class MeasurementService { } } - update(id, measurement) { + update(id, measurement, notYetUpdatedAtSource = false) { if (this.measurements[id]) { const updatedMeasurement = { ...measurement, modifiedTimestamp: Math.floor(Date.now() / 1000), }; - log.info(`Updating measurement...`, updatedMeasurement); + log.info( + `Updating internal measurement representation...`, + updatedMeasurement + ); this.measurements[id] = updatedMeasurement; this._broadcastChange( + // Add an internal flag to say the measurement has not yet been updated at source. this.EVENTS.MEASUREMENT_UPDATED, - measurement.source, - updatedMeasurement + { + source: measurement.source, + measurement: updatedMeasurement, + notYetUpdatedAtSource, + } ); return updatedMeasurement.id; @@ -375,19 +378,17 @@ class MeasurementService { newMeasurement ); this.measurements[internalId] = newMeasurement; - this._broadcastChange( - this.EVENTS.MEASUREMENT_UPDATED, + this._broadcastChange(this.EVENTS.MEASUREMENT_UPDATED, { source, - newMeasurement - ); + measurement: newMeasurement, + }); } else { log.info(`Measurement added.`, newMeasurement); this.measurements[internalId] = newMeasurement; - this._broadcastChange( - this.EVENTS.MEASUREMENT_ADDED, + this._broadcastChange(this.EVENTS.MEASUREMENT_ADDED, { source, - newMeasurement - ); + measurement: newMeasurement, + }); } return newMeasurement.id; @@ -466,37 +467,45 @@ class MeasurementService { newMeasurement ); this.measurements[internalId] = newMeasurement; - this._broadcastChange( - this.EVENTS.MEASUREMENT_UPDATED, + this._broadcastChange(this.EVENTS.MEASUREMENT_UPDATED, { source, - newMeasurement - ); + measurement: newMeasurement, + notYetUpdatedAtSource: false, + }); } else { log.info(`Measurement added.`, newMeasurement); this.measurements[internalId] = newMeasurement; - this._broadcastChange( - this.EVENTS.MEASUREMENT_ADDED, + this._broadcastChange(this.EVENTS.MEASUREMENT_ADDED, { source, - newMeasurement - ); + measurement: newMeasurement, + }); } return newMeasurement.id; } - remove(source, id) { + /** + * Removes a measurement and broadcasts the removed event. + * + * @param {string} id The measurement id + * @param {MeasurementSource} source The measurement source instance + * @return {string} The removed measurement id + */ + remove(id, source) { if (!id || !this.measurements[id]) { log.warn(`No id provided, or unable to find measurement by id.`); return; } delete this.measurements[id]; - this._broadcastChange(this.EVENTS.MEASUREMENT_REMOVED, source, id); + this._broadcastChange(this.EVENTS.MEASUREMENT_REMOVED, { + source, + measurement: id, // This is weird :shrug: + }); } clearMeasurements() { this.measurements = {}; - this._broadcastChange(this.EVENTS.MEASUREMENTS_CLEARED); } @@ -556,7 +565,7 @@ class MeasurementService { * * @param {MeasurementSource} source Measurement source instance * @param {string} definition The source definition - * @param {string} measurement The measurement serice measurement + * @param {Measurement} measurement The measurement service measurement * @return {Object} The mapping based on matched criteria */ _getMatchingMapping(source, definition, measurement) { @@ -610,30 +619,20 @@ class MeasurementService { /** * Broadcasts measurement changes. * - * @param {string} eventName The event name - * @param {MeasurementSource} source The measurement source - * @param {string} measurement The measurement id + * @param {string} eventName The event name.add + * @param {object} eventData.source The measurement source. + * @param {object} eventData.measurement The measurement. + * @param {boolean} eventData.notYetUpdatedAtSource True if the measurement was edited + * within the measurement service and the source needs to update. * @return void */ - _broadcastChange(eventName, source, measurement) { + _broadcastChange(eventName, eventData) { const hasListeners = Object.keys(this.listeners).length > 0; const hasCallbacks = Array.isArray(this.listeners[eventName]); - if (!source) { - /* Broadcast to all sources */ - /* Object.keys(this.sources).forEach(source => { - if (hasListeners && hasCallbacks) { - this.listeners[eventName].forEach(listener => { - listener.callback({ source, measurement }); - }); - } - }); - return; */ - } - if (hasListeners && hasCallbacks) { this.listeners[eventName].forEach(listener => { - listener.callback({ source, measurement }); + listener.callback(eventData); }); } } diff --git a/platform/core/src/services/UIDialogService/index.js b/platform/core/src/services/UIDialogService/index.js index b703be0bd..c6e126ee5 100644 --- a/platform/core/src/services/UIDialogService/index.js +++ b/platform/core/src/services/UIDialogService/index.js @@ -57,6 +57,7 @@ function _create({ preservePosition = true, isDraggable = true, showOverlay = false, + onClickOutside, defaultPosition, }) { return serviceImplementation._create({ @@ -69,6 +70,7 @@ function _create({ centralize, preservePosition, isDraggable, + onClickOutside, showOverlay, defaultPosition, }); diff --git a/platform/ui/index.js b/platform/ui/index.js index 113057bff..81379ac85 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -32,6 +32,7 @@ export { export { Button, ButtonGroup, + ContextMenu, DateRange, Dialog, Dropdown, @@ -76,6 +77,7 @@ export { ThumbnailTracked, ThumbnailList, ToolbarButton, + ContextMenuMeasurements, Tooltip, TooltipClipboard, Typography, diff --git a/platform/ui/src/components/ContextMenu/ContextMenu.jsx b/platform/ui/src/components/ContextMenu/ContextMenu.jsx new file mode 100644 index 000000000..c02154959 --- /dev/null +++ b/platform/ui/src/components/ContextMenu/ContextMenu.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Typography } from '@ohif/ui'; + +const ContextMenu = ({ items }) => { + return ( +
e.preventDefault()} + > + {items.map((item, index) => ( +
item.action(item)} + className="flex px-4 py-3 cursor-pointer items-center transition duration-300 hover:bg-primary-dark border-b border-primary-dark last:border-b-0" + > + {item.label} +
+ ))} +
+ ); +}; + +ContextMenu.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + actionType: PropTypes.string.isRequired, + action: PropTypes.func.isRequired, + }) + ).isRequired, +}; + +export default ContextMenu; diff --git a/platform/ui/src/components/ContextMenu/ContextMenu.mdx b/platform/ui/src/components/ContextMenu/ContextMenu.mdx new file mode 100644 index 000000000..71486c6ec --- /dev/null +++ b/platform/ui/src/components/ContextMenu/ContextMenu.mdx @@ -0,0 +1,49 @@ +--- +name: Context Menu +menu: General +route: components/contextMenu +--- + +import { Playground, Props } from 'docz'; +import { ContextMenu } from '@ohif/ui'; + +# Context Menu + +... + +## Import + +```javascript +import { ContextMenu } from '@ohif/ui'; +``` + + + {() => { + const items = [ + { + label: 'Delete measurement', + actionType: 'Delete', + action: () => alert('Delete'), + }, + { + label: 'Relabel', + actionType: 'setLabel', + action: () => alert('Relabel'), + }, + { + label: 'Add Description', + actionType: 'setDescription', + action: () => alert('Add Description'), + }, + ]; + return ( +
+ +
+ ); + }} +
+ +## Properties + + diff --git a/platform/ui/src/components/ContextMenu/index.js b/platform/ui/src/components/ContextMenu/index.js new file mode 100644 index 000000000..1bf5f07d6 --- /dev/null +++ b/platform/ui/src/components/ContextMenu/index.js @@ -0,0 +1 @@ +export { default } from './ContextMenu'; diff --git a/platform/ui/src/components/ContextMenuMeasurements/ContextMenuMeasurements.jsx b/platform/ui/src/components/ContextMenuMeasurements/ContextMenuMeasurements.jsx new file mode 100644 index 000000000..04f732a6c --- /dev/null +++ b/platform/ui/src/components/ContextMenuMeasurements/ContextMenuMeasurements.jsx @@ -0,0 +1,44 @@ +import { ContextMenu } from '@ohif/ui'; +import PropTypes from 'prop-types'; +import React from 'react'; + +const ContextMenuMeasurements = ({ + onGetMenuItems, + onSetLabel, + onClose, + onDelete, +}) => { + const defaultMenuItems = [ + { + label: 'Delete measurement', + actionType: 'Delete', + action: item => { + onDelete(item); + onClose(); + }, + value: {}, + }, + { + label: 'Add Label', + actionType: 'setLabel', + action: item => { + onSetLabel(item); + onClose(); + }, + value: {}, + }, + ]; + + const menuItems = onGetMenuItems(defaultMenuItems); + + return ; +}; + +ContextMenuMeasurements.propTypes = { + onClose: PropTypes.func.isRequired, + onSetLabel: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + onGetMenuItems: PropTypes.func.isRequired, +}; + +export default ContextMenuMeasurements; diff --git a/platform/ui/src/components/ContextMenuMeasurements/index.js b/platform/ui/src/components/ContextMenuMeasurements/index.js new file mode 100644 index 000000000..fd58615db --- /dev/null +++ b/platform/ui/src/components/ContextMenuMeasurements/index.js @@ -0,0 +1 @@ +export { default } from './ContextMenuMeasurements'; diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index 1288505c8..ee1946abd 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -1,5 +1,6 @@ import Button from './Button'; import ButtonGroup from './ButtonGroup'; +import ContextMenu from './ContextMenu'; import DateRange from './DateRange'; import Dialog from './Dialog'; import Dropdown from './Dropdown'; @@ -41,6 +42,7 @@ import ThumbnailNoImage from './ThumbnailNoImage'; import ThumbnailTracked from './ThumbnailTracked'; import ThumbnailList from './ThumbnailList'; import ToolbarButton from './ToolbarButton'; +import ContextMenuMeasurements from './ContextMenuMeasurements'; import ExpandableToolbarButton from './ExpandableToolbarButton'; import ListMenu from './ListMenu'; import Tooltip from './Tooltip'; @@ -55,6 +57,7 @@ import ViewportPane from './ViewportPane'; export { Button, ButtonGroup, + ContextMenu, DateRange, Dialog, Dropdown, @@ -99,6 +102,7 @@ export { ThumbnailTracked, ThumbnailList, ToolbarButton, + ContextMenuMeasurements, Tooltip, TooltipClipboard, Typography, diff --git a/platform/ui/src/contextProviders/DialogProvider.jsx b/platform/ui/src/contextProviders/DialogProvider.jsx index ec57f0232..c7275038d 100644 --- a/platform/ui/src/contextProviders/DialogProvider.jsx +++ b/platform/ui/src/contextProviders/DialogProvider.jsx @@ -4,7 +4,7 @@ import React, { useContext, useCallback, useEffect, - useRef + useRef, } from 'react'; import PropTypes from 'prop-types'; @@ -154,6 +154,7 @@ const DialogProvider = ({ children, service }) => { onStart, onStop, onDrag, + onClickOutside, showOverlay, } = dialog; @@ -169,7 +170,7 @@ const DialogProvider = ({ children, service }) => { disabled={!isDraggable} position={position} defaultPosition={position} - bounds='parent' + bounds="parent" onStart={event => { const e = event || window.event; const target = e.target || e.srcElement; @@ -225,16 +226,27 @@ const DialogProvider = ({ children, service }) => { const background = 'bg-black bg-opacity-50'; const overlay = 'fixed z-50 left-0 top-0 w-full h-full overflow-auto'; return ( -
+
{component}
); }; - return showOverlay ? withOverlay(dragableItem()) : dragableItem(); + let result = dragableItem(); + + if (showOverlay) { + result = withOverlay(result); + } + + if (typeof onClickOutside === 'function') { + result = ( + + {result} + + ); + } + + return result; }); /** @@ -253,7 +265,7 @@ const DialogProvider = ({ children, service }) => { }; const onKeyDownHandler = event => { - if (event.key === "Escape") { + if (event.key === 'Escape') { dismissAll(); } }; @@ -262,11 +274,11 @@ const DialogProvider = ({ children, service }) => { return ( - {!isEmpty() && -
+ {!isEmpty() && ( +
{renderDialogs()}
- } + )} {children} ); @@ -302,3 +314,29 @@ DialogProvider.propTypes = { }; export default DialogProvider; + +function OutsideAlerter(props) { + const wrapperRef = useRef(null); + + useEffect(() => { + /** + * Alert if clicked on outside of element + */ + function handleInteractionOutside(event) { + if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { + props.onClickOutside(); + } + } + + // Bind the event listener + document.addEventListener('mousedown', handleInteractionOutside); + document.addEventListener('touchstart', handleInteractionOutside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener('mousedown', handleInteractionOutside); + document.removeEventListener('touchstart', handleInteractionOutside); + }; + }, [wrapperRef]); + + return
{props.children}
; +}