ohif-viewer/extensions/cornerstone/src/init.js

590 lines
18 KiB
JavaScript

import OHIF from '@ohif/core';
import { ContextMenuMeasurements } from '@ohif/ui';
import cs from 'cornerstone-core';
import csTools from 'cornerstone-tools';
import merge from 'lodash.merge';
import getTools, { toolsGroupedByType } from './utils/getTools.js';
import initCornerstoneTools from './initCornerstoneTools.js';
import './initWADOImageLoader.js';
import getCornerstoneMeasurementById from './utils/getCornerstoneMeasurementById';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
import { setEnabledElement } from './state';
import callInputDialog from './callInputDialog.js';
// 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',
// SR Viewport...
'SRLength',
'SRBidirectional',
'SRArrowAnnotate',
'SREllipticalRoi',
];
const _refreshViewports = () =>
cs.getEnabledElements().forEach(({ element }) => cs.updateImage(element));
/* Add extension tools configuration here. */
const _createInternalToolsConfig = UIDialogService => {
return {
ArrowAnnotate: {
configuration: {
getTextCallback: (callback, eventDetails) =>
callInputDialog(UIDialogService, null, callback),
changeTextCallback: (data, eventDetails, callback) =>
callInputDialog(UIDialogService, data, callback),
allowEmptyLabel: true,
},
},
DragProbe: {
defaultStrategy: 'minimal',
},
};
};
/**
*
* @param {Object} servicesManager
* @param {Object} configuration
* @param {Object|Array} configuration.csToolsConfig
*/
export default function init({
servicesManager,
commandsManager,
configuration,
}) {
const {
UIDialogService,
MeasurementService,
DisplaySetService,
ToolBarService,
} = servicesManager.services;
const tools = getTools();
console.log(servicesManager.services);
/* 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;
// Sync'd w/ Measurement Service
if (measurementData.id) {
measurementServiceSource.remove(measurementData.id);
}
// Only in cstools
else {
const toolState =
csTools.globalImageIdSpecificToolStateManager.toolState;
Object.keys(toolState).forEach(imageId => {
Object.keys(toolState[imageId]).forEach(imageToolType => {
const numMeasurements =
toolState[imageId][imageToolType].data.length;
for (let i = 0; i < numMeasurements; i++) {
const toolData = toolState[imageId][imageToolType].data[i];
if (measurementData.uuid === toolData.uuid) {
// Clear and nuke matching measurement
toolState[imageId][imageToolType].data[i] = null;
delete toolState[imageId][imageToolType].data[i];
toolState[imageId][imageToolType].data.splice(i, 1);
// Delete "data" if we deleted last measurement in array
// if (toolState[imageId][imageToolType].data.length === 0) {
// delete toolState[imageId][imageToolType].data;
// }
}
}
});
});
console.log(csTools.store.state);
}
_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(
UIDialogService,
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();
}
};
// TODO: This is the handler for ALL ENABLED ELEMENT EVENTS
// ... Activation logic should take place per element, not for all (diff behavior per ext)
function elementEnabledHandler(tools, evt) {
const element = evt.detail.element;
_addConfiguredToolsForElement(
UIDialogService,
element,
tools,
configuration
);
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);
}
const { csToolsConfig } = configuration;
const metadataProvider = OHIF.cornerstone.metadataProvider;
cs.metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999);
// ~~
const defaultCsToolsConfig = csToolsConfig || {
globalToolSyncEnabled: false, // hold on to your pants!
showSVGCursors: false,
autoResizeViewports: false,
};
initCornerstoneTools(defaultCsToolsConfig);
// TODO: Extensions are still registered at time of registration globally
// These should be registered as a part of mode route spin up,
// and they need to self-clean on mode route destroy
// THIS
// is a way for extensions that "depend" on this extension to notify it of
// new cornerstone enabled elements so it's commands continue to work.
const handleOhifCornerstoneEnabledElementEvent = function(evt) {
const { context, viewportIndex, enabledElement } = evt.detail;
setEnabledElement(viewportIndex, enabledElement, context);
};
document.addEventListener(
'ohif-cornerstone-enabled-element-event',
handleOhifCornerstoneEnabledElementEvent
);
cs.events.addEventListener(
cs.EVENTS.ELEMENT_ENABLED,
elementEnabledHandler.bind(null, tools)
);
cs.events.addEventListener(
cs.EVENTS.ELEMENT_DISABLED,
elementDisabledHandler
);
}
const _initMeasurementService = (MeasurementService, DisplaySetService) => {
/* Initialization */
const {
Length,
Bidirectional,
EllipticalRoi,
ArrowAnnotate,
} = measurementServiceMappingsFactory(MeasurementService, DisplaySetService);
const csToolsVer4MeasurementSource = MeasurementService.createSource(
'CornerstoneTools',
'4'
);
/* Mappings */
MeasurementService.addMapping(
csToolsVer4MeasurementSource,
'Length',
Length.matchingCriteria,
Length.toAnnotation,
Length.toMeasurement
);
MeasurementService.addMapping(
csToolsVer4MeasurementSource,
'Bidirectional',
Bidirectional.matchingCriteria,
Bidirectional.toAnnotation,
Bidirectional.toMeasurement
);
MeasurementService.addMapping(
csToolsVer4MeasurementSource,
'EllipticalRoi',
EllipticalRoi.matchingCriteria,
EllipticalRoi.toAnnotation,
EllipticalRoi.toMeasurement
);
MeasurementService.addMapping(
csToolsVer4MeasurementSource,
'ArrowAnnotate',
ArrowAnnotate.matchingCriteria,
ArrowAnnotate.toAnnotation,
ArrowAnnotate.toMeasurement
);
return csToolsVer4MeasurementSource;
};
const _connectToolsToMeasurementService = (
MeasurementService,
DisplaySetService
) => {
const csToolsVer4MeasurementSource = _initMeasurementService(
MeasurementService,
DisplaySetService
);
_connectMeasurementServiceToTools(
MeasurementService,
csToolsVer4MeasurementSource
);
const { addOrUpdate, remove } = csToolsVer4MeasurementSource;
const elementEnabledEvt = cs.EVENTS.ELEMENT_ENABLED;
/* Measurement Service Events */
cs.events.addEventListener(elementEnabledEvt, evt => {
// TODO: Debounced update of measurements that are modified
function addMeasurement(csToolsEvent) {
console.log('CSTOOLS::addOrUpdate', csToolsEvent, csToolsEvent.detail);
try {
const evtDetail = csToolsEvent.detail;
const { toolName, toolType, measurementData } = evtDetail;
const csToolName = toolName || measurementData.toolType || toolType;
const measurementId = addOrUpdate(csToolName, evtDetail);
if (measurementId) {
measurementData.id = measurementId;
}
} catch (error) {
console.warn('Failed to add measurement:', error);
}
}
function updateMeasurement(csToolsEvent) {
try {
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);
}
}
/**
* When csTools fires a removed event, remove the same measurement
* from the measurement service
*
* @param {*} csToolsEvent
*/
function removeMeasurement(csToolsEvent) {
console.log('~~ removeEvt', csToolsEvent);
try {
if (csToolsEvent.detail.measurementData.id) {
remove(csToolsEvent.detail.measurementData.id);
}
} catch (error) {
console.warn('Failed to remove measurement:', error);
}
}
const {
MEASUREMENTS_CLEARED,
MEASUREMENT_UPDATED,
} = MeasurementService.EVENTS;
MeasurementService.subscribe(MEASUREMENTS_CLEARED, () => {
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;
const removedEvt = csTools.EVENTS.MEASUREMENT_REMOVED;
enabledElement.addEventListener(completedEvt, addMeasurement);
enabledElement.addEventListener(updatedEvt, updateMeasurement);
enabledElement.addEventListener(removedEvt, removeMeasurement);
});
return csToolsVer4MeasurementSource;
};
const _connectMeasurementServiceToTools = (
MeasurementService,
measurementSource
) => {
const { MEASUREMENT_REMOVED } = MeasurementService.EVENTS;
const sourceId = measurementSource.id;
// 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
// --> csTools.deleteById --> internally checks all registered modules/managers?
//
// This implementation assumes a single globalImageIdSpecificToolStateManager
// It iterates all toolState for all toolTypes, and deletes any with a matching id
//
// Could potentially use "source" from event to determine tool type and skip some
// iterations?
MeasurementService.subscribe(
MEASUREMENT_REMOVED,
({ source, measurement: removedMeasurementId }) => {
// THIS POINTS TO ORIGINAL; Not a copy
const imageIdSpecificToolState = globalImageIdSpecificToolStateManager.saveToolState();
// ImageId -->
Object.keys(imageIdSpecificToolState).forEach(imageId => {
// ImageId --> Tool -->
Object.keys(imageIdSpecificToolState[imageId]).forEach(toolName => {
const toolState = imageIdSpecificToolState[imageId][toolName];
let annotationIndex = toolState.data.length - 1;
while (annotationIndex >= 0) {
const annotation = toolState.data[annotationIndex];
if (annotation.id === removedMeasurementId) {
toolState.data.splice(annotationIndex, 1);
}
annotationIndex--;
}
});
});
}
);
};
const _getDefaultPosition = event => ({
x: (event && event.currentPoints.client.x) || 0,
y: (event && event.currentPoints.client.y) || 0,
});
/**
* @private
*/
function _addConfiguredToolsForElement(
UIDialogService,
element,
tools,
configuration
) {
const internalToolsConfig = _createInternalToolsConfig(UIDialogService);
/* Add tools with its custom props through extension configuration. */
tools.forEach(tool => {
const toolName = new tool().name;
const externalToolsConfig = configuration.tools || {};
const externalToolProps = externalToolsConfig[toolName] || {};
const internalToolProps = internalToolsConfig[toolName] || {};
const props = merge(
internalToolProps,
_parseToolProps(configuration, externalToolProps, tool)
);
csTools.addToolForElement(element, tool, props);
});
}
/* Abstract tools configuration using extension configuration. */
function _parseToolProps(configuration, props, tool) {
const { annotations } = toolsGroupedByType;
// An alternative approach would be to remove the `drawHandlesOnHover` config
// from the supported configuration properties in `cornerstone-tools`
const toolsWithHideableHandles = annotations.filter(
tool => !['RectangleRoiTool', 'EllipticalRoiTool'].includes(tool.name)
);
let parsedProps = { ...props };
/**
* drawHandles - Never/Always show handles
* drawHandlesOnHover - Only show handles on handle hover (pointNearHandle)
* hideHandlesIfMoving - Hides the handles whilst you are moving them, for better visibility.
*
* Does not apply to tools where handles aren't placed in predictable
* locations.
*/
if (
configuration.hideHandles !== false &&
toolsWithHideableHandles.includes(tool)
) {
if (props.configuration) {
parsedProps.configuration.drawHandlesOnHover = true;
parsedProps.configuration.hideHandlesIfMoving = true;
} else {
parsedProps.configuration = {
drawHandlesOnHover: true,
hideHandlesIfMoving: true,
};
}
}
return parsedProps;
}