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
This commit is contained in:
Bill Wallace authored and GitHub committed 2023-03-22 17:45:28 -04:00
1 parent 4e94b588a7
commit bc642fd2b6
40 files changed
+1472 -280

No files matched your search

@@ -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 <InstanceNumberOverlayItem {...overlayItemProps} />;
} 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;
+214 -9
View File
@@ -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 `<designator>:<codeValue>`
* * 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;
+9 -16
View File
@@ -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<void> {
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 [
{
+9 -143
View File
@@ -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<void> {
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
@@ -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;
+1 -1
View File
@@ -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"
},
@@ -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;
};
}
@@ -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();
});
});
@@ -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<string, unknown>
): 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;
}
@@ -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;
@@ -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,
};
@@ -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<string, unknown>;
// 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<string, unknown>;
// 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;
};
+69 -2
View File
@@ -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: [],
@@ -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;
},
},
],
},
];
@@ -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 };
@@ -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(
@@ -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 `<codingSchemeDesignator>:<codingValue>`
* 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;
@@ -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;
@@ -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;
@@ -0,0 +1,5 @@
import codingValues from './codingValues';
import contextMenuCodeItem from './contextMenuCodeItem';
import findingsContextMenu from './findingsContextMenu';
export { codingValues, contextMenuCodeItem, findingsContextMenu };
@@ -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],
},
];
}
+4
View File
@@ -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;