[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 <rodrigoantinarelli@gmail.com>
Co-authored-by: igoroctaviano <igoroctaviano@gmail.com>
This commit is contained in:
James Petts 2020-08-05 13:56:02 +01:00 committed by GitHub
parent 39e68f3193
commit 401a2e38c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 575 additions and 196 deletions

View File

@ -30,8 +30,8 @@ export default {
* @param {object} [configuration={}] * @param {object} [configuration={}]
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools` * @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
*/ */
preRegistration({ servicesManager, configuration = {} }) { preRegistration({ servicesManager, commandsManager, configuration = {} }) {
init({ servicesManager, configuration }); init({ servicesManager, commandsManager, configuration });
}, },
getViewportModule({ commandsManager }) { getViewportModule({ commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => { const ExtendedOHIFCornerstoneViewport = props => {

View File

@ -1,32 +1,239 @@
import React from 'react'; import React from 'react';
import OHIF from '@ohif/core'; import OHIF from '@ohif/core';
import { Dialog, Input } from '@ohif/ui'; import { Input, Dialog, ContextMenuMeasurements } from '@ohif/ui';
import cornerstone from 'cornerstone-core'; import cs from 'cornerstone-core';
import csTools from 'cornerstone-tools'; import csTools from 'cornerstone-tools';
import merge from 'lodash.merge'; import merge from 'lodash.merge';
import initCornerstoneTools from './initCornerstoneTools.js'; import initCornerstoneTools from './initCornerstoneTools.js';
import cornerstoneTools from 'cornerstone-tools'; import './initWADOImageLoader.js';
import initWADOImageLoader from './initWADOImageLoader.js'; import getCornerstoneMeasurementById from './utils/getCornerstoneMeasurementById';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
//
import { setEnabledElement } from './state'; 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} servicesManager
* @param {Object} configuration * @param {Object} configuration
* @param {Object|Array} configuration.csToolsConfig * @param {Object|Array} configuration.csToolsConfig
*/ */
export default function init({ servicesManager, configuration }) { export default function init({
servicesManager,
commandsManager,
configuration,
}) {
const { const {
UIDialogService, UIDialogService,
MeasurementService, MeasurementService,
DisplaySetService, DisplaySetService,
} = servicesManager.services; } = 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) { if (UIDialogService) {
let dialogId = UIDialogService.create({ UIDialogService.create({
id: dialogId,
centralize: true, centralize: true,
isDraggable: false, isDraggable: false,
content: Dialog, content: Dialog,
@ -34,34 +241,26 @@ export default function init({ servicesManager, configuration }) {
showOverlay: true, showOverlay: true,
contentProps: { contentProps: {
title: 'Enter your annotation', title: 'Enter your annotation',
value: { label: data ? data.text : '' }, value: { label },
noCloseButton: true, noCloseButton: true,
onClose: () => UIDialogService.dismiss({ id: dialogId }), onClose: () => UIDialogService.dismiss({ id: dialogId }),
actions: [ actions: [
{ id: 'cancel', text: 'Cancel', type: 'secondary' }, { id: 'cancel', text: 'Cancel', type: 'secondary' },
{ id: 'save', text: 'Save', type: 'primary' }, { id: 'save', text: 'Save', type: 'primary' },
], ],
onSubmit: ({ action, value }) => { onSubmit: onSubmitHandler,
switch (action.id) {
case 'save':
callback(value.label);
break;
case 'cancel':
callback();
break;
}
UIDialogService.dismiss({ id: dialogId });
},
body: ({ value, setValue }) => { body: ({ value, setValue }) => {
const onChangeHandler = event => { const onChangeHandler = event => {
event.persist(); event.persist();
setValue(value => ({ ...value, label: event.target.value })); setValue(value => ({ ...value, label: event.target.value }));
}; };
const onKeyPressHandler = event => { const onKeyPressHandler = event => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } }); onSubmitHandler({ value, action: { id: 'save' } });
} }
}; };
return ( return (
<div className="p-4 bg-primary-dark"> <div className="p-4 bg-primary-dark">
<Input <Input
@ -84,10 +283,7 @@ export default function init({ servicesManager, configuration }) {
const { csToolsConfig } = configuration; const { csToolsConfig } = configuration;
const metadataProvider = OHIF.cornerstone.metadataProvider; const metadataProvider = OHIF.cornerstone.metadataProvider;
cornerstone.metaData.addProvider( cs.metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999);
metadataProvider.get.bind(metadataProvider),
9999
);
// ~~ // ~~
const defaultCsToolsConfig = csToolsConfig || { const defaultCsToolsConfig = csToolsConfig || {
@ -143,17 +339,14 @@ export default function init({ servicesManager, configuration }) {
tools.push(...toolsGroupedByType[toolsGroup]) tools.push(...toolsGroupedByType[toolsGroup])
); );
/* Measurement Service */
_connectToolsToMeasurementService(MeasurementService, DisplaySetService);
/* Add extension tools configuration here. */ /* Add extension tools configuration here. */
const internalToolsConfig = { const internalToolsConfig = {
ArrowAnnotate: { ArrowAnnotate: {
configuration: { configuration: {
getTextCallback: (callback, eventDetails) => getTextCallback: (callback, eventDetails) =>
callInputDialog(null, eventDetails, callback), callInputDialog(null, callback),
changeTextCallback: (data, eventDetails, callback) => changeTextCallback: (data, eventDetails, callback) =>
callInputDialog(data, eventDetails, callback), callInputDialog(data, callback),
allowEmptyLabel: true, 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('PanMultiTouch', { pointers: 2 }); // TODO: Better error if no options
csTools.setToolActive('ZoomTouchPinch', {}); csTools.setToolActive('ZoomTouchPinch', {});
csTools.setToolEnabled('Overlay', {}); csTools.setToolEnabled('Overlay', {});
cs.events.addEventListener(cs.EVENTS.ELEMENT_ENABLED, elementEnabledHandler);
cs.events.addEventListener(
cs.EVENTS.ELEMENT_DISABLED,
elementDisabledHandler
);
} }
const _initMeasurementService = (MeasurementService, DisplaySetService) => { const _initMeasurementService = (MeasurementService, DisplaySetService) => {
@ -293,12 +492,11 @@ const _connectToolsToMeasurementService = (
csToolsVer4MeasurementSource csToolsVer4MeasurementSource
); );
const { addOrUpdate, remove } = csToolsVer4MeasurementSource; const { addOrUpdate, remove } = csToolsVer4MeasurementSource;
const elementEnabledEvt = cornerstone.EVENTS.ELEMENT_ENABLED; const elementEnabledEvt = cs.EVENTS.ELEMENT_ENABLED;
/* Measurement Service Events */ /* Measurement Service Events */
cornerstone.events.addEventListener(elementEnabledEvt, evt => { cs.events.addEventListener(elementEnabledEvt, evt => {
// TODO: Debounced update of measurements that are modified // TODO: Debounced update of measurements that are modified
function addMeasurement(csToolsEvent) { function addMeasurement(csToolsEvent) {
console.log('CSTOOLS::addOrUpdate', csToolsEvent, csToolsEvent.detail); console.log('CSTOOLS::addOrUpdate', csToolsEvent, csToolsEvent.detail);
@ -322,13 +520,13 @@ const _connectToolsToMeasurementService = (
if (!csToolsEvent.detail.measurementData.id) { if (!csToolsEvent.detail.measurementData.id) {
return; return;
} }
const evtDetail = csToolsEvent.detail; const evtDetail = csToolsEvent.detail;
const { toolName, toolType, measurementData } = evtDetail; const { toolName, toolType, measurementData } = evtDetail;
const csToolName = toolName || measurementData.toolType || toolType; const csToolName = toolName || measurementData.toolType || toolType;
evtDetail.id = csToolsEvent.detail.measurementData.id; evtDetail.id = csToolsEvent.detail.measurementData.id;
addOrUpdate(csToolName, evtDetail); addOrUpdate(csToolName, evtDetail);
//
} catch (error) { } catch (error) {
console.warn('Failed to update measurement:', 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, () => { 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 enabledElement = evt.detail.element;
const completedEvt = csTools.EVENTS.MEASUREMENT_COMPLETED; const completedEvt = csTools.EVENTS.MEASUREMENT_COMPLETED;
const updatedEvt = csTools.EVENTS.MEASUREMENT_MODIFIED; const updatedEvt = csTools.EVENTS.MEASUREMENT_MODIFIED;
@ -368,25 +594,17 @@ const _connectToolsToMeasurementService = (
enabledElement.addEventListener(updatedEvt, updateMeasurement); enabledElement.addEventListener(updatedEvt, updateMeasurement);
enabledElement.addEventListener(removedEvt, removeMeasurement); enabledElement.addEventListener(removedEvt, removeMeasurement);
}); });
return csToolsVer4MeasurementSource;
}; };
const _connectMeasurementServiceToTools = ( const _connectMeasurementServiceToTools = (
MeasurementService, MeasurementService,
measurementSource measurementSource
) => { ) => {
const { const { MEASUREMENT_REMOVED } = MeasurementService.EVENTS;
MEASUREMENTS_CLEARED,
MEASUREMENT_REMOVED,
} = MeasurementService.EVENTS;
const sourceId = measurementSource.id; 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 // TODO: This is an unsafe delete
// Cornerstone-tools should probably expose a more generic "delete by id" // 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 // And have toolState managers expose a method to find any of their toolState by ID
@ -401,7 +619,7 @@ const _connectMeasurementServiceToTools = (
MEASUREMENT_REMOVED, MEASUREMENT_REMOVED,
({ source, measurement: removedMeasurementId }) => { ({ source, measurement: removedMeasurementId }) => {
// THIS POINTS TO ORIGINAL; Not a copy // THIS POINTS TO ORIGINAL; Not a copy
const imageIdSpecificToolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState(); const imageIdSpecificToolState = globalImageIdSpecificToolStateManager.saveToolState();
// ImageId --> // ImageId -->
Object.keys(imageIdSpecificToolState).forEach(imageId => { Object.keys(imageIdSpecificToolState).forEach(imageId => {
@ -425,37 +643,7 @@ const _connectMeasurementServiceToTools = (
); );
}; };
// const { const _getDefaultPosition = event => ({
// MEASUREMENT_ADDED, x: (event && event.currentPoints.client.x) || 0,
// MEASUREMENT_UPDATED, y: (event && event.currentPoints.client.y) || 0,
// } = 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);
// }
// }
// );

View File

@ -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;
}
}
}
}
}
}

View File

@ -1,11 +1,10 @@
import SUPPORTED_TOOLS from './constants/supportedTools'; import SUPPORTED_TOOLS from './constants/supportedTools';
import getPointsFromHandles from './utils/getPointsFromHandles'; import getPointsFromHandles from './utils/getPointsFromHandles';
import getHandlesFromPoints from './utils/getHandlesFromPoints';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
const ArrowAnnotate = { const ArrowAnnotate = {
toAnnotation: (measurement, definition) => { toAnnotation: (measurement, definition) => {},
// TODO -> Implement when this is needed.
},
toMeasurement: ( toMeasurement: (
csToolsAnnotation, csToolsAnnotation,
DisplaySetService, DisplaySetService,
@ -40,7 +39,7 @@ const ArrowAnnotate = {
return { return {
id: measurementData.id, id: measurementData.id,
SOPInstanceUID: SOPInstanceUID, SOPInstanceUID,
FrameOfReferenceUID, FrameOfReferenceUID,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,

View File

@ -1,10 +1,9 @@
import SUPPORTED_TOOLS from './constants/supportedTools'; import SUPPORTED_TOOLS from './constants/supportedTools';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
import getHandlesFromPoints from './utils/getHandlesFromPoints';
const Bidirectional = { const Bidirectional = {
toAnnotation: (measurement, definition) => { toAnnotation: (measurement, definition) => {},
// TODO -> Implement when this is needed.
},
toMeasurement: ( toMeasurement: (
csToolsAnnotation, csToolsAnnotation,
DisplaySetService, DisplaySetService,
@ -41,12 +40,12 @@ const Bidirectional = {
return { return {
id: measurementData.id, id: measurementData.id,
SOPInstanceUID: SOPInstanceUID, SOPInstanceUID,
FrameOfReferenceUID, FrameOfReferenceUID,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,
displaySetInstanceUID: displaySet.displaySetInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: measurementData.text, label: measurementData.label,
description: measurementData.description, description: measurementData.description,
unit: measurementData.unit, unit: measurementData.unit,
shortestDiameter: measurementData.shortestDiameter, shortestDiameter: measurementData.shortestDiameter,

View File

@ -1,10 +1,9 @@
import SUPPORTED_TOOLS from './constants/supportedTools'; import SUPPORTED_TOOLS from './constants/supportedTools';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
import getHandlesFromPoints from './utils/getHandlesFromPoints';
const EllipticalRoi = { const EllipticalRoi = {
toAnnotation: (measurement, definition) => { toAnnotation: (measurement, definition) => {},
// TODO -> Implement when this is needed.
},
toMeasurement: ( toMeasurement: (
csToolsAnnotation, csToolsAnnotation,
DisplaySetService, DisplaySetService,
@ -63,12 +62,12 @@ const EllipticalRoi = {
return { return {
id: measurementData.id, id: measurementData.id,
SOPInstanceUID: SOPInstanceUID, SOPInstanceUID,
FrameOfReferenceUID, FrameOfReferenceUID,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,
displaySetInstanceUID: displaySet.displaySetInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: measurementData.text, label: measurementData.label,
description: measurementData.description, description: measurementData.description,
unit: measurementData.unit, unit: measurementData.unit,
area: area:

View File

@ -4,32 +4,7 @@ import getPointsFromHandles from './utils/getPointsFromHandles';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
const Length = { const Length = {
toAnnotation: (measurement, definition) => { 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,
},
};
},
/** /**
* Maps cornerstone annotation event data to measurement service format. * Maps cornerstone annotation event data to measurement service format.
@ -68,12 +43,12 @@ const Length = {
return { return {
id: measurementData.id, id: measurementData.id,
SOPInstanceUID: SOPInstanceUID, SOPInstanceUID,
FrameOfReferenceUID, FrameOfReferenceUID,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,
displaySetInstanceUID: displaySet.displaySetInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: measurementData.text, label: measurementData.label,
description: measurementData.description, description: measurementData.description,
unit: measurementData.unit, unit: measurementData.unit,
length: measurementData.length, length: measurementData.length,

View File

@ -44,8 +44,8 @@ describe('measurementServiceMappings.js', () => {
toolName: definition, toolName: definition,
measurementData: { measurementData: {
_measurementServiceId: 1, _measurementServiceId: 1,
sopInstanceUid: '123', SOPInstanceUID: '123',
frameOfReferenceUID: '123', FrameOfReferenceUID: '123',
SeriesInstanceUID: '123', SeriesInstanceUID: '123',
handles, handles,
text: 'Test', text: 'Test',

View File

@ -1,4 +1,13 @@
export default function getHandlesFromPoints(points) { 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 return points
.map((p, i) => (i % 10 === 0 ? { start: p } : { end: p })) .map((p, i) => (i % 10 === 0 ? { start: p } : { end: p }))
.reduce((obj, item) => Object.assign(obj, { ...item }), {}); .reduce((obj, item) => Object.assign(obj, { ...item }), {});

View File

@ -162,19 +162,24 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
const onMeasurementItemEditHandler = ({ id }) => { const onMeasurementItemEditHandler = ({ id }) => {
const measurement = MeasurementService.getMeasurement(id); const measurement = MeasurementService.getMeasurement(id);
let dialogId;
const onSubmitHandler = ({ action, value }) => { const onSubmitHandler = ({ action, value }) => {
switch (action.id) { switch (action.id) {
case 'save': { case 'save': {
MeasurementService.update(id, { MeasurementService.update(
id,
{
...measurement, ...measurement,
...value, ...value,
}); },
true
);
} }
} }
UIDialogService.dismiss({ id: dialogId }); UIDialogService.dismiss({ id: 'enter-annotation' });
}; };
dialogId = UIDialogService.create({
UIDialogService.create({
id: 'enter-annotation',
centralize: true, centralize: true,
isDraggable: false, isDraggable: false,
useLastPosition: false, useLastPosition: false,

View File

@ -16,7 +16,7 @@ import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
* *
* @typedef {Object} Measurement * @typedef {Object} Measurement
* @property {number} id - * @property {number} id -
* @property {string} sopInstanceUid - * @property {string} SOPInstanceUID -
* @property {string} FrameOfReferenceUID - * @property {string} FrameOfReferenceUID -
* @property {string} referenceSeriesUID - * @property {string} referenceSeriesUID -
* @property {string} label - * @property {string} label -
@ -50,6 +50,7 @@ const MEASUREMENT_SCHEMA_KEYS = [
const EVENTS = { const EVENTS = {
MEASUREMENT_UPDATED: 'event::measurement_updated', MEASUREMENT_UPDATED: 'event::measurement_updated',
INTERNAL_MEASUREMENT_UPDATED: 'event:internal_measurement_updated',
MEASUREMENT_ADDED: 'event::measurement_added', MEASUREMENT_ADDED: 'event::measurement_added',
MEASUREMENT_REMOVED: 'event::measurement_removed', MEASUREMENT_REMOVED: 'event::measurement_removed',
MEASUREMENTS_CLEARED: 'event::measurements_cleared', MEASUREMENTS_CLEARED: 'event::measurements_cleared',
@ -146,7 +147,7 @@ class MeasurementService {
return this.addOrUpdate(source, definition, measurement); return this.addOrUpdate(source, definition, measurement);
}; };
source.remove = id => { source.remove = id => {
return this.remove(source, id); return this.remove(id, source);
}; };
source.getAnnotation = (definition, measurementId) => { source.getAnnotation = (definition, measurementId) => {
return this.getAnnotation(source, definition, measurementId); return this.getAnnotation(source, definition, measurementId);
@ -216,11 +217,6 @@ class MeasurementService {
return; return;
} }
if (!toSourceSchema) {
log.warn('Source mapping function not provided. Exiting early.');
return;
}
if (!toMeasurementSchema) { if (!toMeasurementSchema) {
log.warn('Measurement mapping function not provided. Exiting early.'); log.warn('Measurement mapping function not provided. Exiting early.');
return; return;
@ -269,9 +265,9 @@ class MeasurementService {
measurementId, measurementId,
definition definition
); );
const measurement = this.getMeasurement(measurementId);
if (mapping) return mapping.toSourceSchema(measurement, definition); if (mapping) return mapping.toSourceSchema(measurement, definition);
const measurement = this.getMeasurement(measurementId);
const matchingMapping = this._getMatchingMapping( const matchingMapping = this._getMatchingMapping(
source, source,
definition, definition,
@ -285,21 +281,28 @@ class MeasurementService {
} }
} }
update(id, measurement) { update(id, measurement, notYetUpdatedAtSource = false) {
if (this.measurements[id]) { if (this.measurements[id]) {
const updatedMeasurement = { const updatedMeasurement = {
...measurement, ...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000), modifiedTimestamp: Math.floor(Date.now() / 1000),
}; };
log.info(`Updating measurement...`, updatedMeasurement); log.info(
`Updating internal measurement representation...`,
updatedMeasurement
);
this.measurements[id] = updatedMeasurement; this.measurements[id] = updatedMeasurement;
this._broadcastChange( this._broadcastChange(
// Add an internal flag to say the measurement has not yet been updated at source.
this.EVENTS.MEASUREMENT_UPDATED, this.EVENTS.MEASUREMENT_UPDATED,
measurement.source, {
updatedMeasurement source: measurement.source,
measurement: updatedMeasurement,
notYetUpdatedAtSource,
}
); );
return updatedMeasurement.id; return updatedMeasurement.id;
@ -375,19 +378,17 @@ class MeasurementService {
newMeasurement newMeasurement
); );
this.measurements[internalId] = newMeasurement; this.measurements[internalId] = newMeasurement;
this._broadcastChange( this._broadcastChange(this.EVENTS.MEASUREMENT_UPDATED, {
this.EVENTS.MEASUREMENT_UPDATED,
source, source,
newMeasurement measurement: newMeasurement,
); });
} else { } else {
log.info(`Measurement added.`, newMeasurement); log.info(`Measurement added.`, newMeasurement);
this.measurements[internalId] = newMeasurement; this.measurements[internalId] = newMeasurement;
this._broadcastChange( this._broadcastChange(this.EVENTS.MEASUREMENT_ADDED, {
this.EVENTS.MEASUREMENT_ADDED,
source, source,
newMeasurement measurement: newMeasurement,
); });
} }
return newMeasurement.id; return newMeasurement.id;
@ -466,37 +467,45 @@ class MeasurementService {
newMeasurement newMeasurement
); );
this.measurements[internalId] = newMeasurement; this.measurements[internalId] = newMeasurement;
this._broadcastChange( this._broadcastChange(this.EVENTS.MEASUREMENT_UPDATED, {
this.EVENTS.MEASUREMENT_UPDATED,
source, source,
newMeasurement measurement: newMeasurement,
); notYetUpdatedAtSource: false,
});
} else { } else {
log.info(`Measurement added.`, newMeasurement); log.info(`Measurement added.`, newMeasurement);
this.measurements[internalId] = newMeasurement; this.measurements[internalId] = newMeasurement;
this._broadcastChange( this._broadcastChange(this.EVENTS.MEASUREMENT_ADDED, {
this.EVENTS.MEASUREMENT_ADDED,
source, source,
newMeasurement measurement: newMeasurement,
); });
} }
return newMeasurement.id; 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]) { if (!id || !this.measurements[id]) {
log.warn(`No id provided, or unable to find measurement by id.`); log.warn(`No id provided, or unable to find measurement by id.`);
return; return;
} }
delete this.measurements[id]; 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() { clearMeasurements() {
this.measurements = {}; this.measurements = {};
this._broadcastChange(this.EVENTS.MEASUREMENTS_CLEARED); this._broadcastChange(this.EVENTS.MEASUREMENTS_CLEARED);
} }
@ -556,7 +565,7 @@ class MeasurementService {
* *
* @param {MeasurementSource} source Measurement source instance * @param {MeasurementSource} source Measurement source instance
* @param {string} definition The source definition * @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 * @return {Object} The mapping based on matched criteria
*/ */
_getMatchingMapping(source, definition, measurement) { _getMatchingMapping(source, definition, measurement) {
@ -610,30 +619,20 @@ class MeasurementService {
/** /**
* Broadcasts measurement changes. * Broadcasts measurement changes.
* *
* @param {string} eventName The event name * @param {string} eventName The event name.add
* @param {MeasurementSource} source The measurement source * @param {object} eventData.source The measurement source.
* @param {string} measurement The measurement id * @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 * @return void
*/ */
_broadcastChange(eventName, source, measurement) { _broadcastChange(eventName, eventData) {
const hasListeners = Object.keys(this.listeners).length > 0; const hasListeners = Object.keys(this.listeners).length > 0;
const hasCallbacks = Array.isArray(this.listeners[eventName]); const hasCallbacks = Array.isArray(this.listeners[eventName]);
if (!source) {
/* Broadcast to all sources */
/* Object.keys(this.sources).forEach(source => {
if (hasListeners && hasCallbacks) { if (hasListeners && hasCallbacks) {
this.listeners[eventName].forEach(listener => { this.listeners[eventName].forEach(listener => {
listener.callback({ source, measurement }); listener.callback(eventData);
});
}
});
return; */
}
if (hasListeners && hasCallbacks) {
this.listeners[eventName].forEach(listener => {
listener.callback({ source, measurement });
}); });
} }
} }

View File

@ -57,6 +57,7 @@ function _create({
preservePosition = true, preservePosition = true,
isDraggable = true, isDraggable = true,
showOverlay = false, showOverlay = false,
onClickOutside,
defaultPosition, defaultPosition,
}) { }) {
return serviceImplementation._create({ return serviceImplementation._create({
@ -69,6 +70,7 @@ function _create({
centralize, centralize,
preservePosition, preservePosition,
isDraggable, isDraggable,
onClickOutside,
showOverlay, showOverlay,
defaultPosition, defaultPosition,
}); });

View File

@ -32,6 +32,7 @@ export {
export { export {
Button, Button,
ButtonGroup, ButtonGroup,
ContextMenu,
DateRange, DateRange,
Dialog, Dialog,
Dropdown, Dropdown,
@ -76,6 +77,7 @@ export {
ThumbnailTracked, ThumbnailTracked,
ThumbnailList, ThumbnailList,
ToolbarButton, ToolbarButton,
ContextMenuMeasurements,
Tooltip, Tooltip,
TooltipClipboard, TooltipClipboard,
Typography, Typography,

View File

@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Typography } from '@ohif/ui';
const ContextMenu = ({ items }) => {
return (
<div
className="relative bg-secondary-dark rounded z-50 block w-48"
onContextMenu={e => e.preventDefault()}
>
{items.map((item, index) => (
<div
key={index}
onClick={() => 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"
>
<Typography>{item.label}</Typography>
</div>
))}
</div>
);
};
ContextMenu.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
actionType: PropTypes.string.isRequired,
action: PropTypes.func.isRequired,
})
).isRequired,
};
export default ContextMenu;

View File

@ -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';
```
<Playground>
{() => {
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 (
<div className="p-4">
<ContextMenu items={items} />
</div>
);
}}
</Playground>
## Properties
<Props of={ContextMenu} />

View File

@ -0,0 +1 @@
export { default } from './ContextMenu';

View File

@ -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 <ContextMenu items={menuItems} />;
};
ContextMenuMeasurements.propTypes = {
onClose: PropTypes.func.isRequired,
onSetLabel: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onGetMenuItems: PropTypes.func.isRequired,
};
export default ContextMenuMeasurements;

View File

@ -0,0 +1 @@
export { default } from './ContextMenuMeasurements';

View File

@ -1,5 +1,6 @@
import Button from './Button'; import Button from './Button';
import ButtonGroup from './ButtonGroup'; import ButtonGroup from './ButtonGroup';
import ContextMenu from './ContextMenu';
import DateRange from './DateRange'; import DateRange from './DateRange';
import Dialog from './Dialog'; import Dialog from './Dialog';
import Dropdown from './Dropdown'; import Dropdown from './Dropdown';
@ -41,6 +42,7 @@ import ThumbnailNoImage from './ThumbnailNoImage';
import ThumbnailTracked from './ThumbnailTracked'; import ThumbnailTracked from './ThumbnailTracked';
import ThumbnailList from './ThumbnailList'; import ThumbnailList from './ThumbnailList';
import ToolbarButton from './ToolbarButton'; import ToolbarButton from './ToolbarButton';
import ContextMenuMeasurements from './ContextMenuMeasurements';
import ExpandableToolbarButton from './ExpandableToolbarButton'; import ExpandableToolbarButton from './ExpandableToolbarButton';
import ListMenu from './ListMenu'; import ListMenu from './ListMenu';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
@ -55,6 +57,7 @@ import ViewportPane from './ViewportPane';
export { export {
Button, Button,
ButtonGroup, ButtonGroup,
ContextMenu,
DateRange, DateRange,
Dialog, Dialog,
Dropdown, Dropdown,
@ -99,6 +102,7 @@ export {
ThumbnailTracked, ThumbnailTracked,
ThumbnailList, ThumbnailList,
ToolbarButton, ToolbarButton,
ContextMenuMeasurements,
Tooltip, Tooltip,
TooltipClipboard, TooltipClipboard,
Typography, Typography,

View File

@ -4,7 +4,7 @@ import React, {
useContext, useContext,
useCallback, useCallback,
useEffect, useEffect,
useRef useRef,
} from 'react'; } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -154,6 +154,7 @@ const DialogProvider = ({ children, service }) => {
onStart, onStart,
onStop, onStop,
onDrag, onDrag,
onClickOutside,
showOverlay, showOverlay,
} = dialog; } = dialog;
@ -169,7 +170,7 @@ const DialogProvider = ({ children, service }) => {
disabled={!isDraggable} disabled={!isDraggable}
position={position} position={position}
defaultPosition={position} defaultPosition={position}
bounds='parent' bounds="parent"
onStart={event => { onStart={event => {
const e = event || window.event; const e = event || window.event;
const target = e.target || e.srcElement; const target = e.target || e.srcElement;
@ -225,16 +226,27 @@ const DialogProvider = ({ children, service }) => {
const background = 'bg-black bg-opacity-50'; const background = 'bg-black bg-opacity-50';
const overlay = 'fixed z-50 left-0 top-0 w-full h-full overflow-auto'; const overlay = 'fixed z-50 left-0 top-0 w-full h-full overflow-auto';
return ( return (
<div <div className={classNames(overlay, background)} key={id}>
className={classNames(overlay, background)}
key={id}
>
{component} {component}
</div> </div>
); );
}; };
return showOverlay ? withOverlay(dragableItem()) : dragableItem(); let result = dragableItem();
if (showOverlay) {
result = withOverlay(result);
}
if (typeof onClickOutside === 'function') {
result = (
<OutsideAlerter onClickOutside={onClickOutside}>
{result}
</OutsideAlerter>
);
}
return result;
}); });
/** /**
@ -253,7 +265,7 @@ const DialogProvider = ({ children, service }) => {
}; };
const onKeyDownHandler = event => { const onKeyDownHandler = event => {
if (event.key === "Escape") { if (event.key === 'Escape') {
dismissAll(); dismissAll();
} }
}; };
@ -262,11 +274,11 @@ const DialogProvider = ({ children, service }) => {
return ( return (
<DialogContext.Provider value={{ create, dismiss, dismissAll, isEmpty }}> <DialogContext.Provider value={{ create, dismiss, dismissAll, isEmpty }}>
{!isEmpty() && {!isEmpty() && (
<div className='w-full h-full absolute' onKeyDown={onKeyDownHandler}> <div className="w-full h-full absolute" onKeyDown={onKeyDownHandler}>
{renderDialogs()} {renderDialogs()}
</div> </div>
} )}
{children} {children}
</DialogContext.Provider> </DialogContext.Provider>
); );
@ -302,3 +314,29 @@ DialogProvider.propTypes = {
}; };
export default DialogProvider; 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 <div ref={wrapperRef}>{props.children}</div>;
}