import { getEnabledElement, StackViewport, VolumeViewport, utilities as csUtils, } from '@cornerstonejs/core'; import { ToolGroupManager, Enums, utilities as cstUtils, ReferenceLinesTool, } from '@cornerstonejs/tools'; import { Types as OhifTypes } from '@ohif/core'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import callInputDialog from './utils/callInputDialog'; import toggleStackImageSync from './utils/stackSync/toggleStackImageSync'; import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; import { CornerstoneServices } from './types'; function commandsModule({ servicesManager, commandsManager, }: OhifTypes.Extensions.ExtensionParams): OhifTypes.Extensions.CommandsModule { const { viewportGridService, toolGroupService, cineService, toolbarService, stateSyncService, uiDialogService, cornerstoneViewportService, uiNotificationService, measurementService, } = servicesManager.services as CornerstoneServices; const { measurementServiceSource } = this; function _getActiveViewportEnabledElement() { return getActiveViewportEnabledElement(viewportGridService); } const actions = { /** * Generates the selector props for the context menu, specific to * the cornerstone viewport, and then runs the context menu. */ showCornerstoneContextMenu: options => { const element = _getActiveViewportEnabledElement()?.viewport?.element; const optionsToUse = { ...options, element }; const { useSelectedAnnotation, nearbyToolData, event } = optionsToUse; // This code is used to invoke the context menu via keyboard shortcuts if (useSelectedAnnotation && !nearbyToolData) { const firstAnnotationSelected = getFirstAnnotationSelected(element); // filter by allowed selected tools from config property (if there is any) const isToolAllowed = !optionsToUse.allowedSelectedTools || optionsToUse.allowedSelectedTools.includes( firstAnnotationSelected?.metadata?.toolName ); if (isToolAllowed) { optionsToUse.nearbyToolData = firstAnnotationSelected; } else { return; } } optionsToUse.defaultPointsPosition = []; // if (optionsToUse.nearbyToolData) { // optionsToUse.defaultPointsPosition = commandsManager.runCommand( // 'getToolDataActiveCanvasPoints', // { toolData: optionsToUse.nearbyToolData } // ); // } // TODO - make the selectorProps richer by including the study metadata and display set. optionsToUse.selectorProps = { toolName: optionsToUse.nearbyToolData?.metadata?.toolName, value: optionsToUse.nearbyToolData, uid: optionsToUse.nearbyToolData?.annotationUID, nearbyToolData: optionsToUse.nearbyToolData, event, ...optionsToUse.selectorProps, }; commandsManager.run(options, optionsToUse); }, getNearbyToolData({ nearbyToolData, element, canvasCoordinates }) { return ( nearbyToolData ?? cstUtils.getAnnotationNearPoint(element, canvasCoordinates) ); }, getNearbyAnnotation({ element, canvasCoordinates }) { const nearbyToolData = actions.getNearbyToolData({ nearbyToolData: null, element, canvasCoordinates, }); const isAnnotation = toolName => { const enabledElement = getEnabledElement(element); if (!enabledElement) { return; } const { renderingEngineId, viewportId } = enabledElement; const toolGroup = ToolGroupManager.getToolGroupForViewport( viewportId, renderingEngineId ); const toolInstance = toolGroup.getToolInstance(toolName); return toolInstance?.constructor?.isAnnotation ?? true; }; return nearbyToolData?.metadata?.toolName && isAnnotation(nearbyToolData.metadata.toolName) ? nearbyToolData : null; }, // Measurement tool commands: /** Delete the given measurement */ deleteMeasurement: ({ uid }) => { if (uid) { measurementServiceSource.remove(uid); } }, /** * Show the measurement labelling input dialog and update the label * on the measurement with a response if not cancelled. */ setMeasurementLabel: ({ uid }) => { const measurement = measurementService.getMeasurement(uid); callInputDialog( uiDialogService, measurement, (label, actionId) => { if (actionId === 'cancel') { return; } const updatedMeasurement = Object.assign({}, measurement, { label, }); measurementService.update( updatedMeasurement.uid, updatedMeasurement, true ); }, false ); }, /** * * @param props - containing the updates to apply * @param props.measurementKey - chooses the measurement key to apply the * code to. This will typically be finding or site to apply a * finind code or a findingSites code. * @param props.code - A coding scheme value from DICOM, including: * * CodeValue - the language independent code, for example '1234' * * CodingSchemeDesignator - the issue of the code value * * CodeMeaning - the text value shown to the user * * ref - a string reference in the form `:` * * Other fields * Note it is a valid option to remove the finding or site values by * supplying null for the code. * @param props.uid - the measurement UID to find it with * @param props.label - the text value for the code. Has NOTHING to do with * the measurement label, which can be set with textLabel * @param props.textLabel is the measurement label to apply. Set to null to * delete. * * If the measurementKey is `site`, then the code will also be added/replace * the 0 element of findingSites. This behaviour is expected to be enhanced * in the future with ability to set other site information. */ updateMeasurement: props => { const { code, uid, textLabel, label } = props; const measurement = measurementService.getMeasurement(uid); const updatedMeasurement = { ...measurement, }; // Call it textLabel as the label value // TODO - remove the label setting when direct rendering of findingSites is enabled if (textLabel !== undefined) { updatedMeasurement.label = textLabel; } if (code !== undefined) { const measurementKey = code.type || 'finding'; if (code.ref && !code.CodeValue) { const split = code.ref.indexOf(':'); code.CodeValue = code.ref.substring(split + 1); code.CodeMeaning = code.text || label; code.CodingSchemeDesignator = code.ref.substring(0, split); } updatedMeasurement[measurementKey] = code; // TODO - remove this line once the measurements table customizations are in if (measurementKey !== 'finding') { if (updatedMeasurement.findingSites) { updatedMeasurement.findingSites = updatedMeasurement.findingSites.filter( it => it.type !== measurementKey ); updatedMeasurement.findingSites.push(code); } else { updatedMeasurement.findingSites = [code]; } } } measurementService.update( updatedMeasurement.uid, updatedMeasurement, true ); }, // Retrieve value commands getActiveViewportEnabledElement: _getActiveViewportEnabledElement, setViewportActive: ({ viewportId }) => { const viewportInfo = cornerstoneViewportService.getViewportInfo( viewportId ); if (!viewportInfo) { console.warn('No viewport found for viewportId:', viewportId); return; } const viewportIndex = viewportInfo.getViewportIndex(); viewportGridService.setActiveViewportIndex(viewportIndex); }, arrowTextCallback: ({ callback, data }) => { callInputDialog(uiDialogService, data, callback); }, toggleCine: () => { const { viewports } = viewportGridService.getState(); const { isCineEnabled } = cineService.getState(); cineService.setIsCineEnabled(!isCineEnabled); toolbarService.setButton('Cine', { props: { isActive: !isCineEnabled } }); viewports.forEach((_, index) => cineService.setCine({ id: index, isPlaying: false }) ); }, setWindowLevel({ window, level, toolGroupId }) { // convert to numbers const windowWidthNum = Number(window); const windowCenterNum = Number(level); const { viewportId } = _getActiveViewportEnabledElement(); const viewportToolGroupId = toolGroupService.getToolGroupForViewport( viewportId ); if (toolGroupId && toolGroupId !== viewportToolGroupId) { return; } // get actor from the viewport const renderingEngine = cornerstoneViewportService.getRenderingEngine(); const viewport = renderingEngine.getViewport(viewportId); const { lower, upper } = csUtils.windowLevel.toLowHighRange( windowWidthNum, windowCenterNum ); viewport.setProperties({ voiRange: { upper, lower, }, }); viewport.render(); }, // Just call the toolbar service record interaction - allows // executing a toolbar command as a full toolbar command with side affects // coming from the ToolbarService itself. toolbarServiceRecordInteraction: props => { toolbarService.recordInteraction(props); }, setToolActive: ({ toolName, toolGroupId = null }) => { if (toolName === 'Crosshairs') { const activeViewportToolGroup = toolGroupService.getToolGroup(null); if (!activeViewportToolGroup._toolInstances.Crosshairs) { uiNotificationService.show({ title: 'Crosshairs', message: 'You need to be in a MPR view to use Crosshairs. Click on MPR button in the toolbar to activate it.', type: 'info', duration: 3000, }); throw new Error('Crosshairs tool is not available in this viewport'); } } const { viewports } = viewportGridService.getState() || { viewports: [], }; const toolGroup = toolGroupService.getToolGroup(toolGroupId); const toolGroupViewportIds = toolGroup?.getViewportIds?.(); // if toolGroup has been destroyed, or its viewports have been removed if (!toolGroupViewportIds || !toolGroupViewportIds.length) { return; } const filteredViewports = viewports.filter(viewport => { if (!viewport.viewportOptions) { return false; } return toolGroupViewportIds.includes( viewport.viewportOptions.viewportId ); }); if (!filteredViewports.length) { return; } if (!toolGroup.getToolInstance(toolName)) { uiNotificationService.show({ title: `${toolName} tool`, message: `The ${toolName} tool is not available in this viewport.`, type: 'info', duration: 3000, }); throw new Error(`ToolGroup ${toolGroup.id} does not have this tool.`); } const activeToolName = toolGroup.getActivePrimaryMouseButtonTool(); if (activeToolName) { // Todo: this is a hack to prevent the crosshairs to stick around // after another tool is selected. We should find a better way to do this if (activeToolName === 'Crosshairs') { toolGroup.setToolDisabled(activeToolName); } else { toolGroup.setToolPassive(activeToolName); } } // Set the new toolName to be active toolGroup.setToolActive(toolName, { bindings: [ { mouseButton: Enums.MouseBindings.Primary, }, ], }); }, showDownloadViewportModal: () => { const { activeViewportIndex } = viewportGridService.getState(); if ( !cornerstoneViewportService.getCornerstoneViewportByIndex( activeViewportIndex ) ) { // Cannot download a non-cornerstone viewport (image). uiNotificationService.show({ title: 'Download Image', message: 'Image cannot be downloaded', type: 'error', }); return; } const { uiModalService } = servicesManager.services; if (uiModalService) { uiModalService.show({ content: CornerstoneViewportDownloadForm, title: 'Download High Quality Image', contentProps: { activeViewportIndex, onClose: uiModalService.hide, cornerstoneViewportService, }, }); } }, rotateViewport: ({ rotation }) => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { return; } const { viewport } = enabledElement; if (viewport instanceof StackViewport) { const { rotation: currentRotation } = viewport.getProperties(); const newRotation = (currentRotation + rotation) % 360; viewport.setProperties({ rotation: newRotation }); viewport.render(); } }, flipViewportHorizontal: () => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { return; } const { viewport } = enabledElement; if (viewport instanceof StackViewport) { const { flipHorizontal } = viewport.getCamera(); viewport.setCamera({ flipHorizontal: !flipHorizontal }); viewport.render(); } }, flipViewportVertical: () => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { return; } const { viewport } = enabledElement; if (viewport instanceof StackViewport) { const { flipVertical } = viewport.getCamera(); viewport.setCamera({ flipVertical: !flipVertical }); viewport.render(); } }, invertViewport: ({ element }) => { let enabledElement; if (element === undefined) { enabledElement = _getActiveViewportEnabledElement(); } else { enabledElement = element; } if (!enabledElement) { return; } const { viewport } = enabledElement; const { invert } = viewport.getProperties(); viewport.setProperties({ invert: !invert }); viewport.render(); }, resetViewport: () => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { return; } const { viewport } = enabledElement; if (viewport instanceof StackViewport) { viewport.resetProperties(); viewport.resetCamera(); } else { // Todo: add reset properties for volume viewport viewport.resetCamera(); } viewport.render(); }, scaleViewport: ({ direction }) => { const enabledElement = _getActiveViewportEnabledElement(); const scaleFactor = direction > 0 ? 0.9 : 1.1; if (!enabledElement) { return; } const { viewport } = enabledElement; if (viewport instanceof StackViewport) { if (direction) { const { parallelScale } = viewport.getCamera(); viewport.setCamera({ parallelScale: parallelScale * scaleFactor }); viewport.render(); } else { viewport.resetCamera(); viewport.render(); } } }, /** Jumps the active viewport or the specified one to the given slice index */ jumpToImage: ({ imageIndex, viewport: gridViewport }): void => { // Get current active viewport (return if none active) let viewport; if (!gridViewport) { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { return; } viewport = enabledElement.viewport; } else { viewport = cornerstoneViewportService.getCornerstoneViewport( gridViewport.id ); } // Get number of slices // -> Copied from cornerstone3D jumpToSlice\_getImageSliceData() let numberOfSlices = 0; if (viewport instanceof StackViewport) { numberOfSlices = viewport.getImageIds().length; } else if (viewport instanceof VolumeViewport) { numberOfSlices = csUtils.getImageSliceDataForVolumeViewport(viewport) .numberOfSlices; } else { throw new Error('Unsupported viewport type'); } const jumpIndex = imageIndex < 0 ? numberOfSlices + imageIndex : imageIndex; if (jumpIndex >= numberOfSlices || jumpIndex < 0) { throw new Error(`Can't jump to ${imageIndex}`); } // Set slice to last slice const options = { imageIndex: jumpIndex }; cstUtils.jumpToSlice(viewport.element, options); }, scroll: ({ direction }) => { const enabledElement = _getActiveViewportEnabledElement(); if (!enabledElement) { return; } const { viewport } = enabledElement; const options = { delta: direction }; cstUtils.scroll(viewport, options); }, setViewportColormap: ({ viewportIndex, displaySetInstanceUID, colormap, immediate = false, }) => { const viewport = cornerstoneViewportService.getCornerstoneViewportByIndex( viewportIndex ); const actorEntries = viewport.getActors(); const actorEntry = actorEntries.find(actorEntry => { return actorEntry.uid.includes(displaySetInstanceUID); }); const { actor: volumeActor, uid: volumeId } = actorEntry; viewport.setProperties({ colormap, volumeActor }, volumeId); if (immediate) { viewport.render(); } }, incrementActiveViewport: () => { const { activeViewportIndex, viewports } = viewportGridService.getState(); const nextViewportIndex = (activeViewportIndex + 1) % viewports.length; viewportGridService.setActiveViewportIndex(nextViewportIndex); }, decrementActiveViewport: () => { const { activeViewportIndex, viewports } = viewportGridService.getState(); const nextViewportIndex = (activeViewportIndex - 1 + viewports.length) % viewports.length; viewportGridService.setActiveViewportIndex(nextViewportIndex); }, toggleStackImageSync: ({ toggledState }) => { toggleStackImageSync({ getEnabledElement, servicesManager, toggledState, }); }, toggleReferenceLines: ({ toggledState }) => { const { activeViewportIndex } = viewportGridService.getState(); const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex( activeViewportIndex ); const viewportId = viewportInfo.getViewportId(); const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); if (!toggledState) { toolGroup.setToolDisabled(ReferenceLinesTool.toolName); } toolGroup.setToolConfiguration( ReferenceLinesTool.toolName, { sourceViewportId: viewportId, }, true // overwrite ); toolGroup.setToolEnabled(ReferenceLinesTool.toolName); }, storePresentation: ({ viewportIndex }) => { const presentation = cornerstoneViewportService.getPresentation( viewportIndex ); if (!presentation || !presentation.presentationIds) { return; } const { lutPresentationStore, positionPresentationStore, } = stateSyncService.getState(); const { presentationIds } = presentation; const { lutPresentationId, positionPresentationId } = presentationIds || {}; const storeState = {}; if (lutPresentationId) { storeState.lutPresentationStore = { ...lutPresentationStore, [lutPresentationId]: presentation, }; } if (positionPresentationId) { storeState.positionPresentationStore = { ...positionPresentationStore, [positionPresentationId]: presentation, }; } stateSyncService.store(storeState); }, }; const definitions = { // The command here is to show the viewer context menu, as being the // context menu showCornerstoneContextMenu: { commandFn: actions.showCornerstoneContextMenu, storeContexts: [], options: { menuCustomizationId: 'measurementsContextMenu', commands: [ { commandName: 'showContextMenu', }, ], }, }, getNearbyToolData: { commandFn: actions.getNearbyToolData, }, getNearbyAnnotation: { commandFn: actions.getNearbyAnnotation, storeContexts: [], options: {}, }, deleteMeasurement: { commandFn: actions.deleteMeasurement, }, setMeasurementLabel: { commandFn: actions.setMeasurementLabel, }, updateMeasurement: { commandFn: actions.updateMeasurement, }, setWindowLevel: { commandFn: actions.setWindowLevel, }, toolbarServiceRecordInteraction: { commandFn: actions.toolbarServiceRecordInteraction, }, setToolActive: { commandFn: actions.setToolActive, }, rotateViewportCW: { commandFn: actions.rotateViewport, options: { rotation: 90 }, }, rotateViewportCCW: { commandFn: actions.rotateViewport, options: { rotation: -90 }, }, incrementActiveViewport: { commandFn: actions.incrementActiveViewport, }, decrementActiveViewport: { commandFn: actions.decrementActiveViewport, }, flipViewportHorizontal: { commandFn: actions.flipViewportHorizontal, }, flipViewportVertical: { commandFn: actions.flipViewportVertical, }, invertViewport: { commandFn: actions.invertViewport, }, resetViewport: { commandFn: actions.resetViewport, }, scaleUpViewport: { commandFn: actions.scaleViewport, options: { direction: 1 }, }, scaleDownViewport: { commandFn: actions.scaleViewport, options: { direction: -1 }, }, fitViewportToWindow: { commandFn: actions.scaleViewport, options: { direction: 0 }, }, nextImage: { commandFn: actions.scroll, options: { direction: 1 }, }, previousImage: { commandFn: actions.scroll, options: { direction: -1 }, }, firstImage: { commandFn: actions.jumpToImage, options: { imageIndex: 0 }, }, lastImage: { commandFn: actions.jumpToImage, options: { imageIndex: -1 }, }, jumpToImage: { commandFn: actions.jumpToImage, }, showDownloadViewportModal: { commandFn: actions.showDownloadViewportModal, }, toggleCine: { commandFn: actions.toggleCine, }, arrowTextCallback: { commandFn: actions.arrowTextCallback, }, setViewportActive: { commandFn: actions.setViewportActive, }, setViewportColormap: { commandFn: actions.setViewportColormap, }, toggleStackImageSync: { commandFn: actions.toggleStackImageSync, }, toggleReferenceLines: { commandFn: actions.toggleReferenceLines, }, storePresentation: { commandFn: actions.storePresentation, storeContexts: [], options: {}, }, }; return { actions, definitions, defaultContext: 'CORNERSTONE', }; } export default commandsModule;