590 lines
18 KiB
JavaScript
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;
|
|
}
|