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.