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 2023-03-22 17:45:28 -04:00 committed by GitHub
parent 4e94b588a7
commit bc642fd2b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1472 additions and 280 deletions

View File

@ -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;

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;

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 [
{

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

View File

@ -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;

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"
},

View File

@ -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;
};
}

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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;

View File

@ -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,
};

View File

@ -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;
};

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: [],

View File

@ -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;
},
},
],
},
];

View File

@ -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 };

View File

@ -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(

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,5 @@
import codingValues from './codingValues';
import contextMenuCodeItem from './contextMenuCodeItem';
import findingsContextMenu from './findingsContextMenu';
export { codingValues, contextMenuCodeItem, findingsContextMenu };

View File

@ -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],
},
];
}

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;

View File

@ -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 = () => {

View File

@ -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) {

View File

@ -36,7 +36,10 @@ export interface ExtensionParams extends ExtensionConstructor {
*/
export interface Extension {
id: string;
preRegistration?: (p: ExtensionParams) => void;
preRegistration?: (p: ExtensionParams) => Promise<void> | 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<string, unknown>;
definitions: Record<string, unknown>;
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`
);

View File

@ -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 {

View File

@ -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[];
}

View File

@ -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 = {
<img src="../../../assets/img/customizable-overlay.png" />
## 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.
<!--

View File

@ -1,20 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Typography } from '../';
import Typography from '../Typography';
import Icon from '../Icon';
const ContextMenu = ({ items }) => {
const ContextMenu = ({ items, ...props }) => {
if (!items) {
console.warn('No items for context menu');
return null;
}
return (
<div
data-cy="context-menu"
className="relative bg-secondary-dark rounded z-50 block w-48"
onContextMenu={e => e.preventDefault()}
>
{items.map((item, index) => (
<div
key={index}
onClick={() => item.action(item)}
data-cy="context-menu-item"
onClick={() => item.action(item, props)}
className="flex px-4 py-3 cursor-pointer items-center transition duration-300 hover:bg-primary-dark border-b border-primary-dark last:border-b-0"
>
<Typography>{item.label}</Typography>
<Typography>
{item.label}
{item.iconRight && (
<Icon name={item.iconRight} className="inline" />
)}
</Typography>
</div>
))}
</div>
@ -25,7 +37,6 @@ ContextMenu.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
actionType: PropTypes.string.isRequired,
action: PropTypes.func.isRequired,
})
).isRequired,

View File

@ -1,44 +0,0 @@
import { ContextMenu } from '../';
import PropTypes from 'prop-types';
import React from 'react';
const ContextMenuMeasurements = ({
onGetMenuItems,
onSetLabel,
onClose,
onDelete,
}) => {
const defaultMenuItems = [
{
label: 'Delete measurement',
actionType: 'Delete',
action: item => {
onDelete(item);
onClose();
},
value: {},
},
{
label: 'Add Label',
actionType: 'setLabel',
action: item => {
onSetLabel(item);
onClose();
},
value: {},
},
];
const menuItems = onGetMenuItems(defaultMenuItems);
return <ContextMenu items={menuItems} />;
};
ContextMenuMeasurements.propTypes = {
onClose: PropTypes.func.isRequired,
onSetLabel: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onGetMenuItems: PropTypes.func.isRequired,
};
export default ContextMenuMeasurements;

View File

@ -1 +0,0 @@
export { default } from './ContextMenuMeasurements';

View File

@ -1,36 +1,52 @@
import React from 'react';
import React, { ReactNode } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { NavBar, Svg, Icon, IconButton, Dropdown } from '../';
function Header({ children, menuOptions, isReturnEnabled, onClickReturnButton, isSticky, WhiteLabeling }) {
function Header({
children,
menuOptions,
isReturnEnabled,
onClickReturnButton,
isSticky,
WhiteLabeling,
...props
}): ReactNode {
const { t } = useTranslation('Header');
// TODO: this should be passed in as a prop instead and the react-router-dom
// dependency should be dropped
const onClickReturn = () => {
if (isReturnEnabled && onClickReturnButton) {
onClickReturnButton()
onClickReturnButton();
}
};
const CustomLogo = (React) => {
return WhiteLabeling.createLogoComponentFn(React)
}
return (
<NavBar className='justify-between border-b-4 border-black' isSticky={isSticky}>
<NavBar
className="justify-between border-b-4 border-black"
isSticky={isSticky}
>
<div className="flex justify-between flex-1">
<div className="flex items-center">
{/* // TODO: Should preserve filter/sort
// Either injected service? Or context (like react router's `useLocation`?) */}
<div
className={classNames("inline-flex items-center mr-3", isReturnEnabled && 'cursor-pointer')}
className={classNames(
'inline-flex items-center mr-3',
isReturnEnabled && 'cursor-pointer'
)}
onClick={onClickReturn}
>
{isReturnEnabled && <Icon name="chevron-left" className="w-8 text-primary-active" />}
<div className="ml-4">{WhiteLabeling ? CustomLogo(React) : <Svg name="logo-ohif" />}</div>
{isReturnEnabled && (
<Icon name="chevron-left" className="w-8 text-primary-active" />
)}
<div className="ml-4">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || (
<Svg name="logo-ohif" />
)}
</div>
</div>
</div>
<div className="flex items-center">{children}</div>
@ -40,7 +56,7 @@ function Header({ children, menuOptions, isReturnEnabled, onClickReturnButton, i
</span>
<Dropdown id="options" showDropdownIcon={false} list={menuOptions}>
<IconButton
id={"options-settings-icon"}
id={'options-settings-icon'}
variant="text"
color="inherit"
size="initial"
@ -49,7 +65,7 @@ function Header({ children, menuOptions, isReturnEnabled, onClickReturnButton, i
<Icon name="settings" />
</IconButton>
<IconButton
id={"options-chevron-down-icon"}
id={'options-chevron-down-icon'}
variant="text"
color="inherit"
size="initial"
@ -76,12 +92,12 @@ Header.propTypes = {
isReturnEnabled: PropTypes.bool,
isSticky: PropTypes.bool,
onClickReturnButton: PropTypes.func,
WhiteLabeling: PropTypes.element,
WhiteLabeling: PropTypes.object,
};
Header.defaultProps = {
isReturnEnabled: true,
isSticky: false
isSticky: false,
};
export default Header;

View File

@ -48,7 +48,6 @@ import ThumbnailNoImage from './ThumbnailNoImage';
import ThumbnailTracked from './ThumbnailTracked';
import ThumbnailList from './ThumbnailList';
import ToolbarButton from './ToolbarButton';
import ContextMenuMeasurements from './ContextMenuMeasurements';
import ExpandableToolbarButton from './ExpandableToolbarButton';
import ListMenu from './ListMenu';
import Tooltip from './Tooltip';
@ -134,7 +133,6 @@ export {
ThumbnailTracked,
ThumbnailList,
ToolbarButton,
ContextMenuMeasurements,
Tooltip,
TooltipClipboard,
Typography,

View File

@ -94,7 +94,6 @@ export {
ThumbnailTracked,
ThumbnailList,
ToolbarButton,
ContextMenuMeasurements,
Tooltip,
TooltipClipboard,
Typography,

View File

@ -0,0 +1,7 @@
export type ContextMenuItem = {
// A label to show for the item.
label: string;
// An icon to show the on right of the text - typically used for submenus
iconRight?: string;
action: (item, component) => void;
};

View File

@ -0,0 +1 @@
export type Predicate = (props: Record<string, unknown>) => boolean;

View File

@ -4,6 +4,9 @@ import { PresentationIds } from '../contextProviders/getPresentationIds';
// A few miscellaneous types declared inline here.
export * from './Predicate';
export * from './ContextMenuItem';
/**
* StringNumber often comes back from DICOMweb for integer valued items.
*/

View File

@ -0,0 +1,42 @@
describe('OHIF Context Menu', function () {
beforeEach(function () {
cy.checkStudyRouteInViewer(
'1.2.840.113619.2.5.1762583153.215519.978957063.78'
);
cy.expectMinimumThumbnails(3);
cy.initCommonElementsAliases();
cy.initCornerstoneToolsAliases();
cy.resetViewport().wait(50);
});
it('checks context menu customization', function () {
// Add length measurement
cy.addLengthMeasurement();
cy.get('[data-cy="prompt-begin-tracking-yes"]').click();
cy.get('[data-cy="measurement-item"]').click();
const [x1, y1] = [150, 100];
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
});
// Contextmenu is visible
cy.get('[data-cy="context-menu"]').should('be.visible');
// Click "Finding" subMenu
cy.get('[data-cy="context-menu-item"]')
.contains('Finding')
.click();
// Click "Finding" subMenu
cy.get('[data-cy="context-menu-item"]')
.contains('Aortic insufficiency')
.click();
cy.get('[data-cy="measurement-item"]').contains('Aortic insufficiency');
});
});

View File

@ -52,7 +52,7 @@ describe('OHIF Study Viewer Page', function() {
it('performs double-click to load thumbnail in active viewport', () => {
// Have to finish rendering the image before this works
cy.wait(250);
cy.wait(350);
cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
//cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText);