From bc642fd2b688c390633c0322aa239fca46367127 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 22 Mar 2023 17:45:28 -0400 Subject: [PATCH] feat: Allow configurable context menus (#2894) * feat: Context menu feat: Custom context menu Adding documentation PR updates * fix: Capture tool exception * PR updates * Add fully worked out examples in the basic test mode/extension * Fix the menu display * fix: Make the commands on clicks much more configurable * Wait for load before double clicking * docs * PR changes - nothing functional, just moving things endlessly * PR comments * PR changes - rename the default context menu * Renamed the cornerstoneContextMenu to measurementsContextMenu * Add chevron right to the sub-menus --- .../Overlays/CustomizableViewportOverlay.tsx | 21 +- extensions/cornerstone/src/commandsModule.ts | 223 +++++++++++++++++- extensions/cornerstone/src/index.tsx | 25 +- extensions/cornerstone/src/init.tsx | 152 +----------- extensions/cornerstone/src/initContextMenu.ts | 128 ++++++++++ extensions/default/package.json | 2 +- .../ContextMenuController.tsx | 208 ++++++++++++++++ .../ContextMenuItemsBuilder.test.js | 29 +++ .../ContextMenuItemsBuilder.ts | 193 +++++++++++++++ .../defaultContextMenu.ts | 31 +++ .../src/CustomizeableContextMenu/index.ts | 11 + .../src/CustomizeableContextMenu/types.ts | 123 ++++++++++ extensions/default/src/commandsModule.ts | 71 +++++- .../default/src/getCustomizationModule.tsx | 26 +- extensions/default/src/{index.js => index.ts} | 22 +- extensions/default/src/{init.js => init.ts} | 2 +- .../src/custom-context-menu/codingValues.ts | 80 +++++++ .../contextMenuCodeItem.ts | 27 +++ .../findingsContextMenu.ts | 100 ++++++++ .../src/custom-context-menu/index.ts | 5 + .../src/getCustomizationModule.ts | 14 ++ extensions/test-extension/src/index.tsx | 4 + modes/basic-test-mode/src/index.js | 7 + platform/core/src/classes/CommandsManager.ts | 2 +- .../core/src/extensions/ExtensionManager.ts | 14 +- .../CustomizationService.ts | 24 +- platform/core/src/types/Command.ts | 6 +- .../services/ui/customization-service.md | 32 ++- .../components/ContextMenu/ContextMenu.tsx | 21 +- .../ContextMenu/{index.js => index.ts} | 0 .../ContextMenuMeasurements.tsx | 44 ---- .../ContextMenuMeasurements/index.js | 1 - platform/ui/src/components/Header/Header.tsx | 46 ++-- platform/ui/src/components/index.js | 2 - platform/ui/src/index.js | 1 - platform/ui/src/types/ContextMenuItem.ts | 7 + platform/ui/src/types/Predicate.ts | 1 + platform/ui/src/types/index.ts | 3 + .../OHIFContextMenuCustomization.spec.js | 42 ++++ .../OHIFStudyBrowser.spec.js | 2 +- 40 files changed, 1472 insertions(+), 280 deletions(-) create mode 100644 extensions/cornerstone/src/initContextMenu.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx create mode 100644 extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js create mode 100644 extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/index.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/types.ts rename extensions/default/src/{index.js => index.ts} (73%) rename extensions/default/src/{init.js => init.ts} (97%) create mode 100644 extensions/test-extension/src/custom-context-menu/codingValues.ts create mode 100644 extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts create mode 100644 extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts create mode 100644 extensions/test-extension/src/custom-context-menu/index.ts create mode 100644 extensions/test-extension/src/getCustomizationModule.ts rename platform/ui/src/components/ContextMenu/{index.js => index.ts} (100%) delete mode 100644 platform/ui/src/components/ContextMenuMeasurements/ContextMenuMeasurements.tsx delete mode 100644 platform/ui/src/components/ContextMenuMeasurements/index.js create mode 100644 platform/ui/src/types/ContextMenuItem.ts create mode 100644 platform/ui/src/types/Predicate.ts create mode 100644 platform/viewer/cypress/integration/measurement-tracking/OHIFContextMenuCustomization.spec.js diff --git a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx index 38f1d206a..bf7665200 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx @@ -117,8 +117,11 @@ function CustomizableViewportOverlay({ viewportIndex, servicesManager, }) { - const { toolbarService, cornerstoneViewportService, customizationService } = - servicesManager.services; + const { + toolbarService, + cornerstoneViewportService, + customizationService, + } = servicesManager.services; const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null }); const [scale, setScale] = useState(1); const [activeTools, setActiveTools] = useState([]); @@ -202,10 +205,9 @@ function CustomizableViewportOverlay({ previousCamera.parallelScale !== camera.parallelScale || previousCamera.scale !== camera.scale ) { - const viewport = - cornerstoneViewportService.getCornerstoneViewportByIndex( - viewportIndex - ); + const viewport = cornerstoneViewportService.getCornerstoneViewportByIndex( + viewportIndex + ); if (!viewport) { return; @@ -283,7 +285,7 @@ function CustomizableViewportOverlay({ } else if (item.customizationType === 'ohif.overlayItem.instanceNumber') { return ; } else { - const renderItem = customizationService.applyType(item); + const renderItem = customizationService.transform(item); if (typeof renderItem.content === 'function') { return renderItem.content(overlayItemProps); @@ -450,8 +452,9 @@ function _getInstanceNumberFromVolume( const volume = volumes[0]; const { direction, imageIds } = volume; - const cornerstoneViewport = - cornerstoneViewportService.getCornerstoneViewportByIndex(viewportIndex); + const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewportByIndex( + viewportIndex + ); if (!cornerstoneViewport) { return; diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 6298792c9..55c212446 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -16,13 +16,10 @@ import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownload import callInputDialog from './utils/callInputDialog'; import { setColormap } from './utils/colormap/transferFunctionHelpers'; import toggleStackImageSync from './utils/stackSync/toggleStackImageSync'; +import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; -const commandsModule = ({ - servicesManager, -}: { - servicesManager: ServicesManager; -}): React.FunctionComponent => { +function commandsModule({ servicesManager, commandsManager }) { const { viewportGridService, toolGroupService, @@ -31,7 +28,12 @@ const commandsModule = ({ uiDialogService, cornerstoneViewportService, uiNotificationService, - } = servicesManager.services; + customizationService, + measurementService, + hangingProtocolService, + } = (servicesManager as ServicesManager).services; + + const { measurementServiceSource } = this; function _getActiveViewportEnabledElement() { return getActiveViewportEnabledElement(viewportGridService); @@ -72,9 +74,175 @@ const commandsModule = ({ } const actions = { - getActiveViewportEnabledElement: () => { - return _getActiveViewportEnabledElement(); + /** + * 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) + ); + }, + + // 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]; + } + } + // TODO - remove this once measurement items customization is ready + const allCodes = []; + if (textLabel) allCodes.push(textLabel); + if (updatedMeasurement.finding) { + allCodes.push(updatedMeasurement.finding.CodeMeaning); + } + (updatedMeasurement.findingSites || []).forEach(it => + allCodes.push(it.CodeMeaning) + ); + updatedMeasurement.label = allCodes.join(', '); + } + measurementService.update( + updatedMeasurement.uid, + updatedMeasurement, + true + ); + }, + + // Retrieve value commands + getActiveViewportEnabledElement: _getActiveViewportEnabledElement, + setViewportActive: ({ viewportId }) => { const viewportInfo = cornerstoneViewportService.getViewportInfo( viewportId @@ -457,6 +625,43 @@ const commandsModule = ({ }; 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, + storeContexts: [], + options: {}, + }, + + deleteMeasurement: { + commandFn: actions.deleteMeasurement, + storeContexts: [], + options: {}, + }, + setMeasurementLabel: { + commandFn: actions.setMeasurementLabel, + storeContexts: [], + options: {}, + }, + updateMeasurement: { + commandFn: actions.updateMeasurement, + storeContexts: [], + options: {}, + }, + setWindowLevel: { commandFn: actions.setWindowLevel, storeContexts: [], @@ -587,6 +792,6 @@ const commandsModule = ({ definitions, defaultContext: 'CORNERSTONE', }; -}; +} export default commandsModule; diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index f4cf97070..f1ec22f16 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -10,7 +10,7 @@ import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools'; import { Types } from '@ohif/core'; import init from './init'; -import commandsModule from './commandsModule'; +import getCommandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import ToolGroupService from './services/ToolGroupService'; import SyncGroupService from './services/SyncGroupService'; @@ -51,7 +51,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { */ id, - onModeExit: () => { + onModeExit: (): void => { // Empty out the image load and retrieval pools to prevent memory leaks // on the mode exits Object.values(cs3DEnums.RequestType).forEach(type => { @@ -68,12 +68,10 @@ const cornerstoneExtension: Types.Extensions.Extension = { * * @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools` */ - async preRegistration({ - servicesManager, - commandsManager, - configuration = {}, - appConfig, - }) { + preRegistration: function ( + props: Types.Extensions.ExtensionParams + ): Promise { + const { servicesManager } = props; // Todo: we should be consistent with how services get registered. Use REGISTRATION static method for all servicesManager.registerService( CornerstoneViewportService(servicesManager) @@ -87,8 +85,9 @@ const cornerstoneExtension: Types.Extensions.Extension = { CornerstoneCacheService.REGISTRATION(servicesManager) ); - await init({ servicesManager, commandsManager, configuration, appConfig }); + return init.call(this, props); }, + getHangingProtocolModule, getViewportModule({ servicesManager, commandsManager }) { const ExtendedOHIFCornerstoneViewport = props => { @@ -114,13 +113,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { }, ]; }, - getCommandsModule({ servicesManager, commandsManager, extensionManager }) { - return commandsModule({ - servicesManager, - commandsManager, - extensionManager, - }); - }, + getCommandsModule, getUtilityModule({ servicesManager }) { return [ { diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 858ac41f1..c44ec812d 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -1,6 +1,5 @@ import OHIF from '@ohif/core'; import React from 'react'; -import { ContextMenuMeasurements } from '@ohif/ui'; import * as cornerstone from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -21,15 +20,11 @@ import initWADOImageLoader from './initWADOImageLoader'; import initCornerstoneTools from './initCornerstoneTools'; import { connectToolsToMeasurementService } from './initMeasurementService'; -import callInputDialog from './utils/callInputDialog'; import initCineService from './initCineService'; import interleaveCenterLoader from './utils/interleaveCenterLoader'; import nthLoader from './utils/nthLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; - -const cs3DToolsEvents = Enums.Events; - -let CONTEXT_MENU_OPEN = false; +import initContextMenu from './initContextMenu'; // TODO: Cypress tests are currently grabbing this from the window? window.cornerstone = cornerstone; @@ -42,7 +37,7 @@ export default async function init({ commandsManager, configuration, appConfig, -}) { +}: Types.Extensions.ExtensionParams): Promise { await cs3DInit(); // For debugging e2e tests that are failing on CI @@ -65,6 +60,7 @@ export default async function init({ const { userAuthenticationService, measurementService, + customizationService, displaySetService, uiDialogService, uiModalService, @@ -155,118 +151,12 @@ export default async function init({ initWADOImageLoader(userAuthenticationService, appConfig); /* Measurement Service */ - const measurementServiceSource = connectToolsToMeasurementService( + this.measurementServiceSource = connectToolsToMeasurementService( servicesManager ); initCineService(cineService); - const _getDefaultPosition = event => ({ - x: (event && event.currentPoints.client[0]) || 0, - y: (event && event.currentPoints.client[1]) || 0, - }); - - 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 = utilities.getAnnotationNearPoint( - element, - currentPoints.canvas - ); - - const menuItems = []; - if (nearbyToolData && nearbyToolData.metadata.toolName !== 'Crosshairs') { - defaultMenuItems.forEach(item => { - item.value = nearbyToolData; - item.element = element; - 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 { annotationUID } = item.value; - - const uid = annotationUID; - // Sync'd w/ Measurement Service - if (uid) { - measurementServiceSource.remove(uid, { - element: item.element, - }); - } - CONTEXT_MENU_OPEN = false; - }, - onClose: () => { - CONTEXT_MENU_OPEN = false; - uiDialogService.dismiss({ id: 'context-menu' }); - }, - onSetLabel: item => { - const { annotationUID } = item.value; - - const measurement = measurementService.getMeasurement(annotationUID); - - callInputDialog( - uiDialogService, - measurement, - (label, actionId) => { - if (actionId === 'cancel') { - return; - } - - const updatedMeasurement = Object.assign({}, measurement, { - label, - }); - - measurementService.update( - updatedMeasurement.uid, - updatedMeasurement, - true - ); - }, - false - ); - - CONTEXT_MENU_OPEN = false; - }, - }, - }); - }; - - 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' }); - }; - // When a custom image load is performed, update the relevant viewports hangingProtocolService.subscribe( hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED, @@ -285,24 +175,11 @@ export default async function init({ } ); - /* - * 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(); - // } - // }; + initContextMenu({ + cornerstoneViewportService, + customizationService, + commandsManager, + }); const newStackCallback = evt => { const { element } = evt.detail; @@ -337,12 +214,6 @@ export default async function init({ function elementEnabledHandler(evt) { const { element } = evt.detail; - - element.addEventListener( - cs3DToolsEvents.MOUSE_CLICK, - contextMenuHandleClick - ); - element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs); eventTarget.addEventListener( @@ -354,11 +225,6 @@ export default async function init({ function elementDisabledHandler(evt) { const { element } = evt.detail; - element.removeEventListener( - cs3DToolsEvents.MOUSE_CLICK, - contextMenuHandleClick - ); - element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs); // TODO - consider removing the callback when all elements are gone diff --git a/extensions/cornerstone/src/initContextMenu.ts b/extensions/cornerstone/src/initContextMenu.ts new file mode 100644 index 000000000..a9f69db51 --- /dev/null +++ b/extensions/cornerstone/src/initContextMenu.ts @@ -0,0 +1,128 @@ +import { eventTarget, EVENTS } from '@cornerstonejs/core'; +import { Enums } from '@cornerstonejs/tools'; +import { setEnabledElement } from './state'; + +const cs3DToolsEvents = Enums.Events; + +const DEFAULT_CONTEXT_MENU_CLICKS = { + button1: { + commands: [ + { + commandName: 'closeContextMenu', + }, + ], + }, + button3: { + commands: [ + { + commandName: 'showCornerstoneContextMenu', + commandOptions: { + menuId: 'measurementsContextMenu', + }, + }, + ], + }, +}; + +/** + * Generates a name, consisting of: + * * alt when the alt key is down + * * ctrl when the cctrl key is down + * * shift when the shift key is down + * * 'button' followed by the button number (1 left, 3 right etc) + */ +function getEventName(evt) { + const button = evt.detail.event.which; + const nameArr = []; + if (evt.detail.event.altKey) nameArr.push('alt'); + if (evt.detail.event.ctrlKey) nameArr.push('ctrl'); + if (evt.detail.event.shiftKey) nameArr.push('shift'); + nameArr.push('button'); + nameArr.push(button); + return nameArr.join(''); +} + +function initContextMenu({ + cornerstoneViewportService, + customizationService, + commandsManager, +}): void { + /** + * Finds tool nearby event position triggered. + * + * @param {Object} commandsManager mannager of commands + * @param {Object} event that has being triggered + * @returns cs toolData or undefined if not found. + */ + const findNearbyToolData = evt => { + if (!evt?.detail) { + return; + } + const { element, currentPoints } = evt.detail; + return commandsManager.runCommand( + 'getNearbyToolData', + { + element, + canvasCoordinates: currentPoints?.canvas, + }, + 'CORNERSTONE' + ); + }; + + /* + * Run the commands associated with the given button press, + * defaults on button1 and button2 + */ + const cornerstoneViewportHandleEvent = (name, evt) => { + const customizations = + customizationService.get('cornerstoneViewportClickCommands') || + DEFAULT_CONTEXT_MENU_CLICKS; + const toRun = customizations[name]; + console.log('initContextMenu::cornerstoneViewportHandleEvent', name, toRun); + const options = { + nearbyToolData: findNearbyToolData(evt), + event: evt, + }; + commandsManager.run(toRun, options); + }; + + const cornerstoneViewportHandleClick = evt => { + const name = getEventName(evt); + cornerstoneViewportHandleEvent(name, evt); + }; + + function elementEnabledHandler(evt) { + const { viewportId, element } = evt.detail; + const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId); + if (!viewportInfo) return; + const viewportIndex = viewportInfo.getViewportIndex(); + // TODO check update upstream + setEnabledElement(viewportIndex, element); + + element.addEventListener( + cs3DToolsEvents.MOUSE_CLICK, + cornerstoneViewportHandleClick + ); + } + + function elementDisabledHandler(evt) { + const { element } = evt.detail; + + element.removeEventListener( + cs3DToolsEvents.MOUSE_CLICK, + cornerstoneViewportHandleClick + ); + } + + eventTarget.addEventListener( + EVENTS.ELEMENT_ENABLED, + elementEnabledHandler.bind(null) + ); + + eventTarget.addEventListener( + EVENTS.ELEMENT_DISABLED, + elementDisabledHandler.bind(null) + ); +} + +export default initContextMenu; diff --git a/extensions/default/package.json b/extensions/default/package.json index 80332ef34..0ca309f62 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -6,7 +6,7 @@ "license": "MIT", "repository": "OHIF/Viewers", "main": "dist/index.umd.js", - "module": "src/index.js", + "module": "src/index.ts", "publishConfig": { "access": "public" }, diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx new file mode 100644 index 000000000..82879bf02 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx @@ -0,0 +1,208 @@ +import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; +import ContextMenu from '../../../../platform/ui/src/components/ContextMenu/ContextMenu'; +import { CommandsManager, ServicesManager, Types } from '@ohif/core'; +import { Menu, MenuItem, Point, ContextMenuProps } from './types'; + +/** + * The context menu controller is a helper class that knows how + * to manage context menus based on the UI Customization Service. + * There are a few parts to this: + * 1. Basic controls to manage displaying and hiding context menus + * 2. Menu selection services, which use the UI customization service + * to choose which menu to display + * 3. Menu item adapter services to convert menu items into displayable and actionable items. + * + * The format for a menu is defined in the exported type MenuItem + */ +export default class ContextMenuController { + commandsManager: CommandsManager; + services: Types.Services; + menuItems: Menu[] | MenuItem[]; + + constructor( + servicesManager: ServicesManager, + commandsManager: CommandsManager + ) { + this.services = servicesManager.services as Obj; + this.commandsManager = commandsManager; + } + + closeContextMenu() { + this.services.uiDialogService.dismiss({ id: 'context-menu' }); + } + + /** + * Figures out which context menu is appropriate to display and shows it. + * + * @param contextMenuProps - the context menu properties, see ./types.ts + * @param viewportElement - the DOM element this context menu is related to + * @param defaultPointsPosition - a default position to show the context menu + */ + showContextMenu( + contextMenuProps: ContextMenuProps, + viewportElement, + defaultPointsPosition + ): void { + if (!this.services.uiDialogService) { + console.warn('Unable to show dialog; no UI Dialog Service available.'); + return; + } + + const { event, subMenu, menuId, menus, selectorProps } = contextMenuProps; + + console.log('Getting items from', menus); + const items = ContextMenuItemsBuilder.getMenuItems( + selectorProps || contextMenuProps, + event, + menus, + menuId + ); + + this.services.uiDialogService.dismiss({ id: 'context-menu' }); + this.services.uiDialogService.create({ + id: 'context-menu', + isDraggable: false, + preservePosition: false, + preventCutOf: true, + defaultPosition: ContextMenuController._getDefaultPosition( + defaultPointsPosition, + event?.detail, + viewportElement + ), + event, + content: ContextMenu, + + // This naming is part of hte uiDialogService convention + // Clicking outside simpy closes the dialog box. + onClickOutside: () => + this.services.uiDialogService.dismiss({ id: 'context-menu' }), + + contentProps: { + items, + selectorProps, + menus, + event, + subMenu, + eventData: event?.detail, + + onClose: () => { + this.services.uiDialogService.dismiss({ id: 'context-menu' }); + }, + + /** + * Displays a sub-menu, removing this menu + * @param {*} item + * @param {*} itemRef + * @param {*} subProps + */ + onShowSubMenu: (item, itemRef, subProps) => { + if (!itemRef.subMenu) { + console.warn('No submenu defined for', item, itemRef, subProps); + return; + } + this.showContextMenu( + { + ...contextMenuProps, + menuId: itemRef.subMenu, + }, + viewportElement, + defaultPointsPosition + ); + }, + + // Default is to run the specified commands. + onDefault: (item, itemRef, subProps) => { + this.commandsManager.run(item, { + ...selectorProps, + ...itemRef, + subProps, + }); + }, + }, + }); + } + + static getDefaultPosition = (): Point => { + return { + x: 0, + y: 0, + }; + }; + + static _getEventDefaultPosition = eventDetail => ({ + x: eventDetail && eventDetail.currentPoints.client[0], + y: eventDetail && eventDetail.currentPoints.client[1], + }); + + static _getElementDefaultPosition = element => { + if (element) { + const boundingClientRect = element.getBoundingClientRect(); + return { + x: boundingClientRect.x, + y: boundingClientRect.y, + }; + } + + return { + x: undefined, + y: undefined, + }; + }; + + static _getCanvasPointsPosition = (points = [], element) => { + const viewerPos = ContextMenuController._getElementDefaultPosition(element); + + for (let pointIndex = 0; pointIndex < points.length; pointIndex++) { + const point = { + x: points[pointIndex][0] || points[pointIndex]['x'], + y: points[pointIndex][1] || points[pointIndex]['y'], + }; + if ( + ContextMenuController._isValidPosition(point) && + ContextMenuController._isValidPosition(viewerPos) + ) { + return { + x: point.x + viewerPos.x, + y: point.y + viewerPos.y, + }; + } + } + }; + + static _isValidPosition = (source): boolean => { + return ( + source && typeof source.x === 'number' && typeof source.y === 'number' + ); + }; + + /** + * Returns the context menu default position. It look for the positions of: canvasPoints (got from selected), event that triggers it, current viewport element + */ + static _getDefaultPosition = (canvasPoints, eventDetail, viewerElement) => { + function* getPositionIterator() { + yield ContextMenuController._getCanvasPointsPosition( + canvasPoints, + viewerElement + ); + yield ContextMenuController._getEventDefaultPosition(eventDetail); + yield ContextMenuController._getElementDefaultPosition(viewerElement); + yield ContextMenuController.getDefaultPosition(); + } + + const positionIterator = getPositionIterator(); + + let current = positionIterator.next(); + let position = current.value; + + while (!current.done) { + position = current.value; + + if (ContextMenuController._isValidPosition(position)) { + positionIterator.return(); + } + current = positionIterator.next(); + } + + return position; + }; +} diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js new file mode 100644 index 000000000..b5555f71f --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js @@ -0,0 +1,29 @@ +import ContextMenuItemsBuilder from "./ContextMenuItemsBuilder"; + +const menus = [ + { + id: 'one', + selector: ({ value }) => value === 'one', + items: [], + }, + { + id: 'two', + selector: ({ value }) => value === 'two', + items: [], + }, + { + id: 'default', + items: [], + }, +]; + +const menuBuilder = new ContextMenuItemsBuilder(); + +describe('ContextMenuItemsBuilder', () => { + test('findMenuDefault', () => { + expect(menuBuilder.findMenuDefault(menus, {})).toBe(menus[2]); + expect(menuBuilder.findMenuDefault(menus, { value: 'two' })).toBe(menus[1]); + expect(menuBuilder.findMenuDefault([], {})).toBeUndefined(); + expect(menuBuilder.findMenuDefault(undefined, undefined)).toBeNull(); + }); +}); diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts new file mode 100644 index 000000000..ad5bc7380 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts @@ -0,0 +1,193 @@ +import { Types } from '@ohif/ui'; +import { Menu, SelectorProps, MenuItem, ContextMenuProps } from './types'; + +type ContextMenuItem = Types.ContextMenuItem; + +/** + * Finds menu by menu id + * + * @returns Menu having the menuId + */ +export function findMenuById(menus: Menu[], menuId?: string): Menu { + if (!menuId) { + return; + } + + return menus.find(menu => menu.id === menuId); +} + +/** + * Default finding menu method. This method will go through + * the list of menus until it finds the first one which + * has no selector, OR has the selector, when applied to the + * check props, return true. + * The selectorProps are a set of provided properties which can be + * passed into the selector function to determine when to display a menu. + * For example, a selector function of: + * `({displayset}) => displaySet?.SeriesDescription?.indexOf?.('Left')!==-1 + * would match series descriptions containing 'Left'. + * + * @param {Object[]} menus List of menus + * @param {*} subProps + * @returns + */ +export function findMenuDefault( + menus: Menu[], + subProps: Record +): Menu { + if (!menus) { + return null; + } + return menus.find( + menu => !menu.selector || menu.selector(subProps.selectorProps) + ); +} + +/** + * Finds the menu to be used for different scenarios: + * This will first look for a subMenu with the specified subMenuId + * Next it will look for the first menu whose selector returns true. + * + * @param menus - List of menus + * @param props - root props + * @param menuIdFilter - menu id identifier (to be considered on selection) + * This is intended to support other types of filtering in the future. + */ +export function findMenu( + menus: Menu[], + props?: Types.IProps, + menuIdFilter?: string +) { + const { subMenu } = props; + + function* findMenuIterator() { + yield findMenuById(menus, menuIdFilter || subMenu); + yield findMenuDefault(menus, props); + } + + const findIt = findMenuIterator(); + + let current = findIt.next(); + let menu = current.value; + + while (!current.done) { + menu = current.value; + + if (menu) { + findIt.return(); + } + current = findIt.next(); + } + + console.log('Menu chosen', menu?.id || 'NONE'); + + return menu; +} + +/** + * Returns the menu from a list of possible menus, based on the actual state of component props and tool data nearby. + * This uses the findMenu command above to first find the appropriate + * menu, and then it chooses the actual contents of that menu. + * A menu item can be optional by implementing the 'selector', + * which will be called with the selectorProps, and if it does not return true, + * then the item is excluded. + * + * Other menus can be delegated to by setting the delegating value to + * a string id for another menu. That menu's content will replace the + * current menu item (only if the item would be included). + * + * This allows single id menus to be chosen by id, but have varying contents + * based on the delegated menus. + * + * Finally, for each item, the adaptItem call is made. This allows + * items to modify themselves before being displayed, such as + * incorporating additional information from translation sources. + * See the `test-mode` examples for details. + * + * @param selectorProps + * @param {*} event event that originates the context menu + * @param {*} menus List of menus + * @param {*} menuIdFilter + * @returns + */ +export function getMenuItems( + selectorProps: Types.IProps, + event: Event, + menus: Menu[], + menuIdFilter?: string +): MenuItem[] | void { + // Include both the check props and the ...check props as one is used + // by the child menu and the other used by the selector function + const subProps = { selectorProps, event }; + + const menu = findMenu(menus, subProps, menuIdFilter); + + if (!menu) { + return undefined; + } + + if (!menu.items) { + console.warn('Must define items in menu', menu); + return []; + } + + let menuItems = []; + menu.items.forEach(item => { + const { delegating, selector, subMenu } = item; + + if (!selector || selector(selectorProps)) { + if (delegating) { + menuItems = [ + ...menuItems, + ...getMenuItems(selectorProps, event, menus, subMenu), + ]; + } else { + const toAdd = adaptItem(item, subProps); + menuItems.push(toAdd); + } + } + }); + + return menuItems; +} + +/** + * Returns item adapted to be consumed by ContextMenu component + * and then goes through the item to add action behaviour for clicking the item, + * making it compatible with the default ContextMenu display. + * + * @param {Object} item + * @param {Object} subProps + * @returns a MenuItem that is compatible with the base ContextMenu + * This requires having a label and set of actions to be called. + */ +export function adaptItem( + item: MenuItem, + subProps: ContextMenuProps +): ContextMenuItem { + const newItem: ContextMenuItem = { + ...item, + value: subProps.selectorProps?.value, + }; + + if (item.actionType === 'ShowSubMenu' && !newItem.iconRight) { + newItem.iconRight = 'chevron-right'; + } + if (!item.action) { + newItem.action = (itemRef, componentProps) => { + const { event = {} } = componentProps; + const { detail = {} } = event; + newItem.element = detail.element; + + componentProps.onClose(); + const action = componentProps[`on${itemRef.actionType || 'Default'}`]; + if (action) { + action.call(componentProps, newItem, itemRef, subProps); + } else { + console.warn('No action defined for', itemRef); + } + }; + } + + return newItem; +} diff --git a/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts b/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts new file mode 100644 index 000000000..29a760c79 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts @@ -0,0 +1,31 @@ +const defaultContextMenu = { + id: 'measurementsContextMenu', + customizationType: 'ohif.contextMenu', + menus: [ + // Get the items from the UI Customization for the menu name (and have a custom name) + { + id: 'forExistingMeasurement', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + label: 'Delete measurement', + commands: [ + { + commandName: 'deleteMeasurement', + }, + ], + }, + { + label: 'Add Label', + commands: [ + { + commandName: 'setMeasurementLabel', + }, + ], + }, + ], + }, + ], +}; + +export default defaultContextMenu; diff --git a/extensions/default/src/CustomizeableContextMenu/index.ts b/extensions/default/src/CustomizeableContextMenu/index.ts new file mode 100644 index 000000000..d630bcad9 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/index.ts @@ -0,0 +1,11 @@ +import ContextMenuController from './ContextMenuController'; +import ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; +import defaultContextMenu from './defaultContextMenu'; +import * as CustomizeableContextMenuTypes from './types'; + +export { + ContextMenuController, + CustomizeableContextMenuTypes, + ContextMenuItemsBuilder, + defaultContextMenu, +}; diff --git a/extensions/default/src/CustomizeableContextMenu/types.ts b/extensions/default/src/CustomizeableContextMenu/types.ts new file mode 100644 index 000000000..d0ffb4787 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/types.ts @@ -0,0 +1,123 @@ +import { Types } from '@ohif/core'; + +/** + * SelectorProps are properties used to decide whether to select a manu or + * menu item for display. + * An instance of SelectorProps is provided to the selector functions, which + * return true to include the item or false to exclude it. + * The point of this is to allow more specific conext menus which hide + * non-relevant menu options, optimizing the speed of selection of menus + * (See Bill Wallace's masters thesis for selection time versus complexity of user menus). + */ +export interface SelectorProps { + // If the context menu is invoked in the context of a measurement, then it + // will contain the nearby tool data. + nearbyToolData?: Record; + + // The tool name for the nearby tool + toolName?: string; + + // An annotation UID - this will be present if nearyToolData is present. + uid?: string; + + // If the context menu is invoked on an active viewport, then it will contain + // the first display set. + displaySet?: Record; + + // The triggering event - can be used to determine key modifiers + event?: Event; + + // Any other properties + [propertyName: string]: unknown; +} + +/** + * The type of item actually required for the ContextMenu UI display + */ +export type UIMenuItem = { + label: string; + // Called when the item is selected + action?: (itemRef, componentProps) => void; +}; + +/** + * A MenuItem is a single line item within a menu, and specifies a selectable + * value for the menu. + */ +export interface MenuItem { + id?: string; + /** The customization type is used to apply preset values to this item + * when registered with the customization service. + */ + customizationType?: string; + + // The label is the value to show in the menu for this item + label?: string; + + // Delegating items are used to include other sub-menus inline within + // this menu. That allows sharing part of the menu structure, but also, + // more importantly to use a single selector function to include/exclude + // and entire section of sub-menu. + // See the `siteSelectionSubMenu` within the example `findingsMenu` + // for an example + delegating?: boolean; + + // A sub-menu is shown when this item is selected or is delegating. + // This item gives the name of the sub-menu. + subMenu?: string; + + // The selector is used to determine if this menu entry will be shown + // or more importantly, if the delegating subMenu will be included. + selector?: (props: SelectorProps) => boolean; + + /** Adapts the item by filling in additional properties as requried */ + adaptItem?: (item: MenuItem, props: ContextMenuProps) => UIMenuItem; + + /** List of commands to run when this item's action is taken. */ + commands?: Types.Command[]; +} + +/** + * A menu is a list of menu items, plus a selector. + * The selector is used to determine whether the menu should be displayed + * in a given context. The parameters passed to the selector come from + * the 'selectorProps' value in the options, and are intended to be context + * specific values containing things like the selected object, the currently + * displayed study etc so that the context menu can dynamically choose which + * view to show. + */ +export interface Menu { + id: string; + + /** The customization type is used to apply preset values to this item + * when registered with the customization service. + */ + customizationType?: string; + + // Choose whether this menu applies. + selector?: Types.Predicate; + + items: MenuItem[]; +} + +export type Point = { + x: number; + y: number; +}; + +/** + * ContextMenuProps is the top level argument used to invoke the context menu + * itself. It contains the menus available for display, as well as the event + * and selector props used to decide the menu. + */ +export type ContextMenuProps = { + event?: EventTarget; + subMenu?: string; + menuId: string; + + /** A set of menus to choose from for this context menu */ + menus: Menu[]; + + /** The properties used to decide the menu type */ + selectorProps: SelectorProps; +}; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 58c3e3797..524db2812 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -1,5 +1,9 @@ -import { DicomMetadataStore, ServicesManager } from '@ohif/core'; +import { ServicesManager, Types } from '@ohif/core'; +import { + ContextMenuController, + defaultContextMenu, +} from './CustomizeableContextMenu'; import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; import reuseCachedLayouts from './utils/reuseCachedLayouts'; import findViewportsByPosition, { @@ -23,8 +27,12 @@ const isHangingProtocolCommand = command => (command.commandName === 'setHangingProtocol' || command.commandName === 'toggleHangingProtocol'); -const commandsModule = ({ servicesManager, commandsManager }) => { +const commandsModule = ({ + servicesManager, + commandsManager, +}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => { const { + customizationService, measurementService, hangingProtocolService, uiNotificationService, @@ -34,7 +42,60 @@ const commandsModule = ({ servicesManager, commandsManager }) => { toolbarService, } = (servicesManager as ServicesManager).services; + // Define a context menu controller for use with any context menus + const contextMenuController = new ContextMenuController( + servicesManager, + commandsManager + ); + const actions = { + /** + * Show the context menu. + * @param options.menuId defines the menu name to lookup, from customizationService + * @param options.defaultMenu contains the default menu set to use + * @param options.element is the element to show the menu within + * @param options.event is the event that caused the context menu + * @param options.selectorProps is the set of selection properties to use + */ + showContextMenu: options => { + const { + menuCustomizationId, + element, + event, + selectorProps, + defaultPointsPosition = [], + } = options; + + const optionsToUse = { ...options }; + + if (menuCustomizationId) { + Object.assign( + optionsToUse, + customizationService.get(menuCustomizationId, defaultContextMenu) + ); + } + + // TODO - make the selectorProps richer by including the study metadata and display set. + const { protocol, stage } = hangingProtocolService.getActiveProtocol(); + optionsToUse.selectorProps = { + event, + protocol, + stage, + ...selectorProps, + }; + + contextMenuController.showContextMenu( + optionsToUse, + element, + defaultPointsPosition + ); + }, + + /** Close a context menu currently displayed */ + closeContextMenu: () => { + contextMenuController.closeContextMenu(); + }, + displayNotification: ({ text, title, type }) => { uiNotificationService.show({ title: title, @@ -336,6 +397,12 @@ const commandsModule = ({ servicesManager, commandsManager }) => { }; const definitions = { + showContextMenu: { + commandFn: actions.showContextMenu, + }, + closeContextMenu: { + commandFn: actions.closeContextMenu, + }, clearMeasurements: { commandFn: actions.clearMeasurements, storeContexts: [], diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index 47d5d1718..babc47250 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -1,3 +1,4 @@ +import { CustomizationService } from '@ohif/core'; import React from 'react'; import DataSourceSelector from './Panels/DataSourceSelector'; @@ -82,7 +83,6 @@ export default function getCustomizationModule() { */ { id: 'ohif.overlayItem', - uiType: 'uiType', content: function (props) { if (this.condition && !this.condition(props)) return null; @@ -91,8 +91,8 @@ export default function getCustomizationModule() { instance && this.attribute ? instance[this.attribute] : this.contentF && typeof this.contentF === 'function' - ? this.contentF(props) - : null; + ? this.contentF(props) + : null; if (!value) return null; return ( @@ -109,6 +109,26 @@ export default function getCustomizationModule() { ); }, }, + + { + id: 'ohif.contextMenu', + + /** Applies the customizationType to all the menu items */ + transform: function (customizationService: CustomizationService) { + // Don't modify the children, as those are copied by reference + const clonedObject = { ...this }; + clonedObject.menus = this.menus.map(it => ({ ...it })); + + for (const menu of clonedObject.menus) { + const { items: originalItems } = menu; + menu.items = []; + for (const item of originalItems) { + menu.items.push(customizationService.transform(item)); + } + } + return clonedObject; + }, + }, ], }, ]; diff --git a/extensions/default/src/index.js b/extensions/default/src/index.ts similarity index 73% rename from extensions/default/src/index.js rename to extensions/default/src/index.ts index bb76b0afc..bb4a63c52 100644 --- a/extensions/default/src/index.js +++ b/extensions/default/src/index.ts @@ -1,32 +1,34 @@ +import { Types } from '@ohif/core'; + import getDataSourcesModule from './getDataSourcesModule.js'; import getLayoutTemplateModule from './getLayoutTemplateModule.js'; import getPanelModule from './getPanelModule'; import getSopClassHandlerModule from './getSopClassHandlerModule.js'; import getToolbarModule from './getToolbarModule'; -import commandsModule from './commandsModule'; +import getCommandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID'; import getCustomizationModule from './getCustomizationModule'; import { id } from './id.js'; -import init from './init'; +import preRegistration from './init'; +import { + ContextMenuController, + CustomizeableContextMenuTypes, +} from './CustomizeableContextMenu'; -const defaultExtension = { +const defaultExtension: Types.Extensions.Extension = { /** * Only required property. Should be a unique value across all extensions. */ id, - preRegistration: ({ servicesManager, configuration = {} }) => { - init({ servicesManager, configuration }); - }, + preRegistration, getDataSourcesModule, getLayoutTemplateModule, getPanelModule, getHangingProtocolModule, getSopClassHandlerModule, getToolbarModule, - getCommandsModule({ servicesManager, commandsManager }) { - return commandsModule({ servicesManager, commandsManager }); - }, + getCommandsModule, getUtilityModule({ servicesManager }) { return [ { @@ -42,3 +44,5 @@ const defaultExtension = { }; export default defaultExtension; + +export { ContextMenuController, CustomizeableContextMenuTypes }; diff --git a/extensions/default/src/init.js b/extensions/default/src/init.ts similarity index 97% rename from extensions/default/src/init.js rename to extensions/default/src/init.ts index 7c7d487dc..b979bb4f6 100644 --- a/extensions/default/src/init.js +++ b/extensions/default/src/init.ts @@ -10,7 +10,7 @@ const metadataProvider = classes.MetadataProvider; * @param {Object} servicesManager * @param {Object} configuration */ -export default function init({ servicesManager, configuration }) { +export default function init({ servicesManager, configuration = {} }): void { const { stateSyncService } = servicesManager.services; // Add DicomMetadataStore.subscribe( diff --git a/extensions/test-extension/src/custom-context-menu/codingValues.ts b/extensions/test-extension/src/custom-context-menu/codingValues.ts new file mode 100644 index 000000000..d5c4743c1 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/codingValues.ts @@ -0,0 +1,80 @@ +/** + * Coding values is a map of simple string coding values to a set of + * attributes associated with the coding value. + * + * The simple string is in the format `:` + * That allows extracting the DICOM attributes from the designator/value, and + * allows for passing around the simple string. + * The additional attributes contained in the object include: + * * text - this is the coding scheme text display value, and may be language specific + * * type - this defines a named type, typically 'site'. Different names can be used + * to allow setting different findingSites values in order to define a hierarchy. + * * color - used to apply annotation color + * It is also possible to define additional attributes here, used by custom + * extensions. + * + * See https://dicom.nema.org/medical/dicom/current/output/html/part16.html + * for definitions of SCT and other code values. + */ +const codingValues = { + id: 'codingValues', + + // Sites + 'SCT:69536005': { + text: 'Head', + type: 'site', + }, + 'SCT:45048000': { + text: 'Neck', + type: 'site', + }, + 'SCT:818981001': { + text: 'Abdomen', + type: 'site', + }, + 'SCT:816092008': { + text: 'Pelvis', + type: 'site', + }, + + // Findings + 'SCT:371861004': { + text: 'Mild intimal coronary irregularities', + color: 'green', + }, + 'SCT:194983005': { + text: 'Aortic insufficiency', + color: 'darkred', + }, + 'SCT:399232001': { + text: '2-chamber', + }, + 'SCT:103340004': { + text: 'SAX', + }, + 'SCT:91134007': { + text: 'MV', + }, + 'SCT:122972007': { + text: 'PV', + }, + + // Orientations + 'SCT:24422004': { + text: 'Axial', + color: '#000000', + type: 'orientation', + }, + 'SCT:81654009': { + text: 'Coronal', + color: '#000000', + type: 'orientation', + }, + 'SCT:30730003': { + text: 'Sagittal', + color: '#000000', + type: 'orientation', + }, +}; + +export default codingValues; diff --git a/extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts b/extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts new file mode 100644 index 000000000..4e054e4f1 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts @@ -0,0 +1,27 @@ +import codingValues from './codingValues'; + +const codeMenuItem = { + id: '@ohif/contextMenuAnnotationCode', + codingValues, + + /** Applies the code value setup for this item */ + transform: function (customizationService) { + const { code: codeRef } = this; + if (!codeRef) throw new Error(`item ${this} has no code ref`); + const codingValues = customizationService.get('codingValues'); + const code = codingValues[codeRef]; + return { + ...this, + codeRef, + code: { ref: codeRef, ...code }, + label: code.text, + commands: [ + { + commandName: 'updateMeasurement', + }, + ], + }; + }, +}; + +export default codeMenuItem; diff --git a/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts b/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts new file mode 100644 index 000000000..ccfc0fb54 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts @@ -0,0 +1,100 @@ +const findingsContextMenu = { + id: 'measurementsContextMenu', + customizationType: 'ohif.contextMenu', + menus: [ + { + id: 'forExistingMeasurement', + // selector restricts context menu to when there is nearbyToolData + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: 'ohif.contextSubMenu', + label: 'Site', + actionType: 'ShowSubMenu', + subMenu: 'siteSelectionSubMenu', + }, + { + customizationType: 'ohif.contextSubMenu', + label: 'Finding', + actionType: 'ShowSubMenu', + subMenu: 'findingSelectionSubMenu', + }, + { + // customizationType is implicit here in the configuration setup + label: 'Delete Measurement', + commands: [ + { + commandName: 'deleteMeasurement', + }, + ], + }, + { + label: 'Add Label', + commands: [ + { + commandName: 'setMeasurementLabel', + }, + ], + }, + + // The example below shows how to include a delegating sub-menu, + // Only available on the @ohif/hp-extension.mn hanging protocol + // To demonstrate, select the 3x1 layout from the protocol menu + // and right click on a measurement. + { + label: 'IncludeSubMenu', + selector: ({ protocol }) => protocol?.id === '@ohif/hp-extension.mn', + delegating: true, + subMenu: 'orientationSelectionSubMenu', + }, + ], + }, + + { + id: 'orientationSelectionSubMenu', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:24422004', + }, + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:81654009', + }, + ], + }, + + { + id: 'findingSelectionSubMenu', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:371861004', + }, + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:194983005', + }, + ], + }, + + { + id: 'siteSelectionSubMenu', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:69536005', + }, + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:45048000', + }, + ], + }, + ], +}; + +export default findingsContextMenu; diff --git a/extensions/test-extension/src/custom-context-menu/index.ts b/extensions/test-extension/src/custom-context-menu/index.ts new file mode 100644 index 000000000..800e31f06 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/index.ts @@ -0,0 +1,5 @@ +import codingValues from './codingValues'; +import contextMenuCodeItem from './contextMenuCodeItem'; +import findingsContextMenu from './findingsContextMenu'; + +export { codingValues, contextMenuCodeItem, findingsContextMenu }; diff --git a/extensions/test-extension/src/getCustomizationModule.ts b/extensions/test-extension/src/getCustomizationModule.ts new file mode 100644 index 000000000..03df13ed0 --- /dev/null +++ b/extensions/test-extension/src/getCustomizationModule.ts @@ -0,0 +1,14 @@ +import { + codingValues, + contextMenuCodeItem, + findingsContextMenu, +} from './custom-context-menu'; + +export default function getCustomizationModule() { + return [ + { + name: 'custom-context-menu', + value: [codingValues, contextMenuCodeItem, findingsContextMenu], + }, + ]; +} diff --git a/extensions/test-extension/src/index.tsx b/extensions/test-extension/src/index.tsx index bbd34d639..589d87ca5 100644 --- a/extensions/test-extension/src/index.tsx +++ b/extensions/test-extension/src/index.tsx @@ -3,6 +3,7 @@ import { Types } from '@ohif/core'; import { id } from './id'; import getHangingProtocolModule from './hp'; +import getCustomizationModule from './getCustomizationModule'; // import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan'; import sameAs from './custom-attribute/sameAs'; import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets'; @@ -59,6 +60,9 @@ const testExtension: Types.Extensions.Extension = { /** Registers some additional hanging protocols. See hp/index.tsx for more details */ getHangingProtocolModule, + + /** Registers some customizations */ + getCustomizationModule, }; export default testExtension; diff --git a/modes/basic-test-mode/src/index.js b/modes/basic-test-mode/src/index.js index a6b5b21a5..ca6dc25fd 100644 --- a/modes/basic-test-mode/src/index.js +++ b/modes/basic-test-mode/src/index.js @@ -72,6 +72,7 @@ function modeFactory() { measurementService, toolbarService, toolGroupService, + customizationService, } = servicesManager.services; measurementService.clearMeasurements(); @@ -79,6 +80,12 @@ function modeFactory() { // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); + // init customizations + console.log('* Adding mode customizations'); + customizationService.addModeCustomizations([ + '@ohif/extension-test.customizationModule.custom-context-menu', + ]); + let unsubscribe; const activateTool = () => { diff --git a/platform/core/src/classes/CommandsManager.ts b/platform/core/src/classes/CommandsManager.ts index 7525f4f7d..34a825c4d 100644 --- a/platform/core/src/classes/CommandsManager.ts +++ b/platform/core/src/classes/CommandsManager.ts @@ -106,7 +106,7 @@ export class CommandsManager { * @param {String} commandName - Command to find * @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts */ - getCommand = (commandName, contextName) => { + getCommand = (commandName: string, contextName?: string) => { const contexts = []; if (contextName) { diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index ca05871af..f4f629224 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -36,7 +36,10 @@ export interface ExtensionParams extends ExtensionConstructor { */ export interface Extension { id: string; - preRegistration?: (p: ExtensionParams) => void; + preRegistration?: (p: ExtensionParams) => Promise | void; + onModeExit?: () => void; + getHangingProtocolModule?: (p: ExtensionParams) => unknown; + getCommandsModule?: (p: ExtensionParams) => CommandsModule; } export type ExtensionRegister = { @@ -44,6 +47,12 @@ export type ExtensionRegister = { create: (p: ExtensionParams) => Extension; }; +export type CommandsModule = { + actions: Record; + definitions: Record; + defaultContext?: string; +}; + export default class ExtensionManager { private _commandsManager: CommandsManager; private _servicesManager: ServicesManager; @@ -331,7 +340,7 @@ export default class ExtensionManager { } try { - const extensionModule = getModuleFn({ + const extensionModule = extension[getModuleFnName]({ appConfig: this._appConfig, commandsManager: this._commandsManager, servicesManager: this._servicesManager, @@ -348,6 +357,7 @@ export default class ExtensionManager { return extensionModule; } catch (ex) { + console.log(ex); throw new Error( `Exception thrown while trying to call ${getModuleFnName} for the ${extensionId} extension` ); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index 1b3442742..91353a59c 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -123,7 +123,7 @@ export default class CustomizationService extends PubSubService { * may have been extended with any customizationType extensions provided, * so you cannot just use `|| defaultValue` * @return A customization to use if one is found, or the default customization, - * both enhanced with any customizationType inheritance (see applyType) + * both enhanced with any customizationType inheritance (see transform) */ public getCustomization( customizationId: string, @@ -145,7 +145,7 @@ export default class CustomizationService extends PubSubService { this.globalCustomizations[customizationId] ?? this.modeCustomizations[customizationId] ?? defaultValue; - return this.applyType(customization); + return this.transform(customization); } public hasModeCustomization(customizationId: string) { @@ -154,6 +154,16 @@ export default class CustomizationService extends PubSubService { this.modeCustomizations[customizationId] ); } + /** + * get is an alias for getModeCustomization, as it is the generic getter + * which will return both mode and global customizations, and should be + * used generally. + * Note that the second parameter, defaultValue, will be expanded to include + * any customizationType values defined in it, so it is not the same as doing: + * `customizationService.get('key') || defaultValue` + * unless the defaultValue does not contain any customizationType definitions. + */ + public get = this.getModeCustomization; /** * Applies any inheritance due to UI Type customization. @@ -161,14 +171,16 @@ export default class CustomizationService extends PubSubService { * and if that is found, will assign all iterable values from that * type into the new type, allowing default behaviour to be configured. */ - public applyType(customization: Customization): Customization { + public transform(customization: Customization): Customization { if (!customization) return customization; const { customizationType } = customization; if (!customizationType) return customization; const parent = this.getCustomization(customizationType); - return parent + const result = parent ? Object.assign(Object.create(parent), customization) : customization; + // Execute an nested type information + return result.transform?.(this) || result; } public addModeCustomizations(modeCustomizations): void { @@ -195,7 +207,7 @@ export default class CustomizationService extends PubSubService { id: string, defaultValue?: Customization ): Customization | void { - return this.applyType(this.globalCustomizations[id] ?? defaultValue); + return this.transform(this.globalCustomizations[id] ?? defaultValue); } setGlobalCustomization(id: string, value: Customization): void { @@ -235,7 +247,7 @@ export default class CustomizationService extends PubSubService { const extensionValue = this.findExtensionValue(value); // The child of a reference is only a set of references when an array, // so call the addReference direct. It could be a secondary reference perhaps - this.addReference(extensionValue); + this.addReference(extensionValue.value, isGlobal, extensionValue.name); } else if (Array.isArray(value)) { this.addReferences(value, isGlobal); } else { diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts index 7e71976e2..0b6129d0c 100644 --- a/platform/core/src/types/Command.ts +++ b/platform/core/src/types/Command.ts @@ -4,9 +4,7 @@ export interface Command { context?: string; } -/** - * This is the format used within many items for multiple commands - */ +/** A set of commands, typically contained in a tool item or other configuration */ export interface Commands { - commands: []; + commands: Commands[]; } diff --git a/platform/docs/docs/platform/services/ui/customization-service.md b/platform/docs/docs/platform/services/ui/customization-service.md index 996f11b93..d1e81de60 100644 --- a/platform/docs/docs/platform/services/ui/customization-service.md +++ b/platform/docs/docs/platform/services/ui/customization-service.md @@ -210,8 +210,8 @@ example (this example comes from the context menu customizations as that one uses commands lists): ```ts - cornerstoneContextMenu = uiConfigurationService.getModeCustomization("cornerstoneContextMenu", defaultMenu); - uiConfigurationService.recordInteraction(cornerstoneContextMenu, extraProps); + cornerstoneContextMenu = uiConfigurationService.get("cornerstoneContextMenu", defaultMenu); + commandsManager.run(cornerstoneContextMenu, extraProps); ``` ### Global Customizations @@ -229,7 +229,7 @@ This allows for having strong typing when declaring customizations, for example: ```ts import { Types } from '@ohif/ui'; -const customContextMenu: Types.UIContextMenu = +const customContextMenu: Types.ContextMenu.Menu = { id: 'cornerstoneContextMenu', customizationType: 'ohif.contextMenu', @@ -260,7 +260,7 @@ getCustomizationModule = () => ([ ``` defines an overlay item which has a React content object as the render value. -This can then be used by specifying a customizationType of `ohif.overlayItem`, for example: +This can then be used by specifying a `customizationType` of `ohif.overlayItem`, for example: ```js const overlayItem: Types.UIOverlayItem = { @@ -275,7 +275,6 @@ const overlayItem: Types.UIOverlayItem = { This section can be used to specify various customization capabilities. - ## Text color for StudyBrowser tabs This is the recommended pattern for deep customization of class attributes, @@ -478,6 +477,29 @@ window.config = { +## Context Menus + +Context menus can be created by defining the menu structure and click +interaction, as defined in the `ContextMenu/types`. There are examples +below specific to the cornerstone context, because the actual click +handler and attributes used to decide when and how to display the menu +are specific to the context used for where the menu is displayed. + +## Cornerstone Context Menu + +The default cornerstone context menu can be customized by setting the +`cornerstoneContextMenu`. For a full example, see `findingsContextMenu`. + +## Customizeable Cornerstone Viewport Click Behaviour + +The behaviour on clicking on the cornerstone viewport can be customized +by setting the `cornerstoneViewportClickCommands`. This is intended to +support both the cornerstone 3D internal commands as well as things like +context menus. Currently it supports buttons 1-3, as well as modifier keys +by associated a commands list with the button to click. See `initContextMenu` +for more details. + +## Please add additional customizations above this section > 3rd Party implementers may be added to this table via pull requests.