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:
parent
4e94b588a7
commit
bc642fd2b6
@ -117,8 +117,11 @@ function CustomizableViewportOverlay({
|
|||||||
viewportIndex,
|
viewportIndex,
|
||||||
servicesManager,
|
servicesManager,
|
||||||
}) {
|
}) {
|
||||||
const { toolbarService, cornerstoneViewportService, customizationService } =
|
const {
|
||||||
servicesManager.services;
|
toolbarService,
|
||||||
|
cornerstoneViewportService,
|
||||||
|
customizationService,
|
||||||
|
} = servicesManager.services;
|
||||||
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
|
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [activeTools, setActiveTools] = useState([]);
|
const [activeTools, setActiveTools] = useState([]);
|
||||||
@ -202,8 +205,7 @@ function CustomizableViewportOverlay({
|
|||||||
previousCamera.parallelScale !== camera.parallelScale ||
|
previousCamera.parallelScale !== camera.parallelScale ||
|
||||||
previousCamera.scale !== camera.scale
|
previousCamera.scale !== camera.scale
|
||||||
) {
|
) {
|
||||||
const viewport =
|
const viewport = cornerstoneViewportService.getCornerstoneViewportByIndex(
|
||||||
cornerstoneViewportService.getCornerstoneViewportByIndex(
|
|
||||||
viewportIndex
|
viewportIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -283,7 +285,7 @@ function CustomizableViewportOverlay({
|
|||||||
} else if (item.customizationType === 'ohif.overlayItem.instanceNumber') {
|
} else if (item.customizationType === 'ohif.overlayItem.instanceNumber') {
|
||||||
return <InstanceNumberOverlayItem {...overlayItemProps} />;
|
return <InstanceNumberOverlayItem {...overlayItemProps} />;
|
||||||
} else {
|
} else {
|
||||||
const renderItem = customizationService.applyType(item);
|
const renderItem = customizationService.transform(item);
|
||||||
|
|
||||||
if (typeof renderItem.content === 'function') {
|
if (typeof renderItem.content === 'function') {
|
||||||
return renderItem.content(overlayItemProps);
|
return renderItem.content(overlayItemProps);
|
||||||
@ -450,8 +452,9 @@ function _getInstanceNumberFromVolume(
|
|||||||
const volume = volumes[0];
|
const volume = volumes[0];
|
||||||
const { direction, imageIds } = volume;
|
const { direction, imageIds } = volume;
|
||||||
|
|
||||||
const cornerstoneViewport =
|
const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewportByIndex(
|
||||||
cornerstoneViewportService.getCornerstoneViewportByIndex(viewportIndex);
|
viewportIndex
|
||||||
|
);
|
||||||
|
|
||||||
if (!cornerstoneViewport) {
|
if (!cornerstoneViewport) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -16,13 +16,10 @@ import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownload
|
|||||||
import callInputDialog from './utils/callInputDialog';
|
import callInputDialog from './utils/callInputDialog';
|
||||||
import { setColormap } from './utils/colormap/transferFunctionHelpers';
|
import { setColormap } from './utils/colormap/transferFunctionHelpers';
|
||||||
import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
|
import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
|
||||||
|
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
|
||||||
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
|
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
|
||||||
|
|
||||||
const commandsModule = ({
|
function commandsModule({ servicesManager, commandsManager }) {
|
||||||
servicesManager,
|
|
||||||
}: {
|
|
||||||
servicesManager: ServicesManager;
|
|
||||||
}): React.FunctionComponent => {
|
|
||||||
const {
|
const {
|
||||||
viewportGridService,
|
viewportGridService,
|
||||||
toolGroupService,
|
toolGroupService,
|
||||||
@ -31,7 +28,12 @@ const commandsModule = ({
|
|||||||
uiDialogService,
|
uiDialogService,
|
||||||
cornerstoneViewportService,
|
cornerstoneViewportService,
|
||||||
uiNotificationService,
|
uiNotificationService,
|
||||||
} = servicesManager.services;
|
customizationService,
|
||||||
|
measurementService,
|
||||||
|
hangingProtocolService,
|
||||||
|
} = (servicesManager as ServicesManager).services;
|
||||||
|
|
||||||
|
const { measurementServiceSource } = this;
|
||||||
|
|
||||||
function _getActiveViewportEnabledElement() {
|
function _getActiveViewportEnabledElement() {
|
||||||
return getActiveViewportEnabledElement(viewportGridService);
|
return getActiveViewportEnabledElement(viewportGridService);
|
||||||
@ -72,9 +74,175 @@ const commandsModule = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
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 }) => {
|
setViewportActive: ({ viewportId }) => {
|
||||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(
|
const viewportInfo = cornerstoneViewportService.getViewportInfo(
|
||||||
viewportId
|
viewportId
|
||||||
@ -457,6 +625,43 @@ const commandsModule = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const definitions = {
|
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: {
|
setWindowLevel: {
|
||||||
commandFn: actions.setWindowLevel,
|
commandFn: actions.setWindowLevel,
|
||||||
storeContexts: [],
|
storeContexts: [],
|
||||||
@ -587,6 +792,6 @@ const commandsModule = ({
|
|||||||
definitions,
|
definitions,
|
||||||
defaultContext: 'CORNERSTONE',
|
defaultContext: 'CORNERSTONE',
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export default commandsModule;
|
export default commandsModule;
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
|||||||
import { Types } from '@ohif/core';
|
import { Types } from '@ohif/core';
|
||||||
|
|
||||||
import init from './init';
|
import init from './init';
|
||||||
import commandsModule from './commandsModule';
|
import getCommandsModule from './commandsModule';
|
||||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||||
import ToolGroupService from './services/ToolGroupService';
|
import ToolGroupService from './services/ToolGroupService';
|
||||||
import SyncGroupService from './services/SyncGroupService';
|
import SyncGroupService from './services/SyncGroupService';
|
||||||
@ -51,7 +51,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
*/
|
*/
|
||||||
id,
|
id,
|
||||||
|
|
||||||
onModeExit: () => {
|
onModeExit: (): void => {
|
||||||
// Empty out the image load and retrieval pools to prevent memory leaks
|
// Empty out the image load and retrieval pools to prevent memory leaks
|
||||||
// on the mode exits
|
// on the mode exits
|
||||||
Object.values(cs3DEnums.RequestType).forEach(type => {
|
Object.values(cs3DEnums.RequestType).forEach(type => {
|
||||||
@ -68,12 +68,10 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
*
|
*
|
||||||
* @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools`
|
* @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools`
|
||||||
*/
|
*/
|
||||||
async preRegistration({
|
preRegistration: function (
|
||||||
servicesManager,
|
props: Types.Extensions.ExtensionParams
|
||||||
commandsManager,
|
): Promise<void> {
|
||||||
configuration = {},
|
const { servicesManager } = props;
|
||||||
appConfig,
|
|
||||||
}) {
|
|
||||||
// Todo: we should be consistent with how services get registered. Use REGISTRATION static method for all
|
// Todo: we should be consistent with how services get registered. Use REGISTRATION static method for all
|
||||||
servicesManager.registerService(
|
servicesManager.registerService(
|
||||||
CornerstoneViewportService(servicesManager)
|
CornerstoneViewportService(servicesManager)
|
||||||
@ -87,8 +85,9 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
CornerstoneCacheService.REGISTRATION(servicesManager)
|
CornerstoneCacheService.REGISTRATION(servicesManager)
|
||||||
);
|
);
|
||||||
|
|
||||||
await init({ servicesManager, commandsManager, configuration, appConfig });
|
return init.call(this, props);
|
||||||
},
|
},
|
||||||
|
|
||||||
getHangingProtocolModule,
|
getHangingProtocolModule,
|
||||||
getViewportModule({ servicesManager, commandsManager }) {
|
getViewportModule({ servicesManager, commandsManager }) {
|
||||||
const ExtendedOHIFCornerstoneViewport = props => {
|
const ExtendedOHIFCornerstoneViewport = props => {
|
||||||
@ -114,13 +113,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
|
getCommandsModule,
|
||||||
return commandsModule({
|
|
||||||
servicesManager,
|
|
||||||
commandsManager,
|
|
||||||
extensionManager,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getUtilityModule({ servicesManager }) {
|
getUtilityModule({ servicesManager }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import OHIF from '@ohif/core';
|
import OHIF from '@ohif/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ContextMenuMeasurements } from '@ohif/ui';
|
|
||||||
|
|
||||||
import * as cornerstone from '@cornerstonejs/core';
|
import * as cornerstone from '@cornerstonejs/core';
|
||||||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||||||
@ -21,15 +20,11 @@ import initWADOImageLoader from './initWADOImageLoader';
|
|||||||
import initCornerstoneTools from './initCornerstoneTools';
|
import initCornerstoneTools from './initCornerstoneTools';
|
||||||
|
|
||||||
import { connectToolsToMeasurementService } from './initMeasurementService';
|
import { connectToolsToMeasurementService } from './initMeasurementService';
|
||||||
import callInputDialog from './utils/callInputDialog';
|
|
||||||
import initCineService from './initCineService';
|
import initCineService from './initCineService';
|
||||||
import interleaveCenterLoader from './utils/interleaveCenterLoader';
|
import interleaveCenterLoader from './utils/interleaveCenterLoader';
|
||||||
import nthLoader from './utils/nthLoader';
|
import nthLoader from './utils/nthLoader';
|
||||||
import interleaveTopToBottom from './utils/interleaveTopToBottom';
|
import interleaveTopToBottom from './utils/interleaveTopToBottom';
|
||||||
|
import initContextMenu from './initContextMenu';
|
||||||
const cs3DToolsEvents = Enums.Events;
|
|
||||||
|
|
||||||
let CONTEXT_MENU_OPEN = false;
|
|
||||||
|
|
||||||
// TODO: Cypress tests are currently grabbing this from the window?
|
// TODO: Cypress tests are currently grabbing this from the window?
|
||||||
window.cornerstone = cornerstone;
|
window.cornerstone = cornerstone;
|
||||||
@ -42,7 +37,7 @@ export default async function init({
|
|||||||
commandsManager,
|
commandsManager,
|
||||||
configuration,
|
configuration,
|
||||||
appConfig,
|
appConfig,
|
||||||
}) {
|
}: Types.Extensions.ExtensionParams): Promise<void> {
|
||||||
await cs3DInit();
|
await cs3DInit();
|
||||||
|
|
||||||
// For debugging e2e tests that are failing on CI
|
// For debugging e2e tests that are failing on CI
|
||||||
@ -65,6 +60,7 @@ export default async function init({
|
|||||||
const {
|
const {
|
||||||
userAuthenticationService,
|
userAuthenticationService,
|
||||||
measurementService,
|
measurementService,
|
||||||
|
customizationService,
|
||||||
displaySetService,
|
displaySetService,
|
||||||
uiDialogService,
|
uiDialogService,
|
||||||
uiModalService,
|
uiModalService,
|
||||||
@ -155,118 +151,12 @@ export default async function init({
|
|||||||
initWADOImageLoader(userAuthenticationService, appConfig);
|
initWADOImageLoader(userAuthenticationService, appConfig);
|
||||||
|
|
||||||
/* Measurement Service */
|
/* Measurement Service */
|
||||||
const measurementServiceSource = connectToolsToMeasurementService(
|
this.measurementServiceSource = connectToolsToMeasurementService(
|
||||||
servicesManager
|
servicesManager
|
||||||
);
|
);
|
||||||
|
|
||||||
initCineService(cineService);
|
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
|
// When a custom image load is performed, update the relevant viewports
|
||||||
hangingProtocolService.subscribe(
|
hangingProtocolService.subscribe(
|
||||||
hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED,
|
hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED,
|
||||||
@ -285,24 +175,11 @@ export default async function init({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
initContextMenu({
|
||||||
* Because click gives us the native "mouse up", buttons will always be `0`
|
cornerstoneViewportService,
|
||||||
* Need to fallback to event.which;
|
customizationService,
|
||||||
*
|
commandsManager,
|
||||||
*/
|
});
|
||||||
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();
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
const newStackCallback = evt => {
|
const newStackCallback = evt => {
|
||||||
const { element } = evt.detail;
|
const { element } = evt.detail;
|
||||||
@ -337,12 +214,6 @@ export default async function init({
|
|||||||
|
|
||||||
function elementEnabledHandler(evt) {
|
function elementEnabledHandler(evt) {
|
||||||
const { element } = evt.detail;
|
const { element } = evt.detail;
|
||||||
|
|
||||||
element.addEventListener(
|
|
||||||
cs3DToolsEvents.MOUSE_CLICK,
|
|
||||||
contextMenuHandleClick
|
|
||||||
);
|
|
||||||
|
|
||||||
element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
|
element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
|
||||||
|
|
||||||
eventTarget.addEventListener(
|
eventTarget.addEventListener(
|
||||||
@ -354,11 +225,6 @@ export default async function init({
|
|||||||
function elementDisabledHandler(evt) {
|
function elementDisabledHandler(evt) {
|
||||||
const { element } = evt.detail;
|
const { element } = evt.detail;
|
||||||
|
|
||||||
element.removeEventListener(
|
|
||||||
cs3DToolsEvents.MOUSE_CLICK,
|
|
||||||
contextMenuHandleClick
|
|
||||||
);
|
|
||||||
|
|
||||||
element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
|
element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
|
||||||
|
|
||||||
// TODO - consider removing the callback when all elements are gone
|
// TODO - consider removing the callback when all elements are gone
|
||||||
|
|||||||
128
extensions/cornerstone/src/initContextMenu.ts
Normal file
128
extensions/cornerstone/src/initContextMenu.ts
Normal 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;
|
||||||
@ -6,7 +6,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": "OHIF/Viewers",
|
"repository": "OHIF/Viewers",
|
||||||
"main": "dist/index.umd.js",
|
"main": "dist/index.umd.js",
|
||||||
"module": "src/index.js",
|
"module": "src/index.ts",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"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;
|
||||||
11
extensions/default/src/CustomizeableContextMenu/index.ts
Normal file
11
extensions/default/src/CustomizeableContextMenu/index.ts
Normal 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,
|
||||||
|
};
|
||||||
123
extensions/default/src/CustomizeableContextMenu/types.ts
Normal file
123
extensions/default/src/CustomizeableContextMenu/types.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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 DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
|
||||||
import reuseCachedLayouts from './utils/reuseCachedLayouts';
|
import reuseCachedLayouts from './utils/reuseCachedLayouts';
|
||||||
import findViewportsByPosition, {
|
import findViewportsByPosition, {
|
||||||
@ -23,8 +27,12 @@ const isHangingProtocolCommand = command =>
|
|||||||
(command.commandName === 'setHangingProtocol' ||
|
(command.commandName === 'setHangingProtocol' ||
|
||||||
command.commandName === 'toggleHangingProtocol');
|
command.commandName === 'toggleHangingProtocol');
|
||||||
|
|
||||||
const commandsModule = ({ servicesManager, commandsManager }) => {
|
const commandsModule = ({
|
||||||
|
servicesManager,
|
||||||
|
commandsManager,
|
||||||
|
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
||||||
const {
|
const {
|
||||||
|
customizationService,
|
||||||
measurementService,
|
measurementService,
|
||||||
hangingProtocolService,
|
hangingProtocolService,
|
||||||
uiNotificationService,
|
uiNotificationService,
|
||||||
@ -34,7 +42,60 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
|
|||||||
toolbarService,
|
toolbarService,
|
||||||
} = (servicesManager as ServicesManager).services;
|
} = (servicesManager as ServicesManager).services;
|
||||||
|
|
||||||
|
// Define a context menu controller for use with any context menus
|
||||||
|
const contextMenuController = new ContextMenuController(
|
||||||
|
servicesManager,
|
||||||
|
commandsManager
|
||||||
|
);
|
||||||
|
|
||||||
const actions = {
|
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 }) => {
|
displayNotification: ({ text, title, type }) => {
|
||||||
uiNotificationService.show({
|
uiNotificationService.show({
|
||||||
title: title,
|
title: title,
|
||||||
@ -336,6 +397,12 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const definitions = {
|
const definitions = {
|
||||||
|
showContextMenu: {
|
||||||
|
commandFn: actions.showContextMenu,
|
||||||
|
},
|
||||||
|
closeContextMenu: {
|
||||||
|
commandFn: actions.closeContextMenu,
|
||||||
|
},
|
||||||
clearMeasurements: {
|
clearMeasurements: {
|
||||||
commandFn: actions.clearMeasurements,
|
commandFn: actions.clearMeasurements,
|
||||||
storeContexts: [],
|
storeContexts: [],
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { CustomizationService } from '@ohif/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DataSourceSelector from './Panels/DataSourceSelector';
|
import DataSourceSelector from './Panels/DataSourceSelector';
|
||||||
|
|
||||||
@ -82,7 +83,6 @@ export default function getCustomizationModule() {
|
|||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
id: 'ohif.overlayItem',
|
id: 'ohif.overlayItem',
|
||||||
uiType: 'uiType',
|
|
||||||
content: function (props) {
|
content: function (props) {
|
||||||
if (this.condition && !this.condition(props)) return null;
|
if (this.condition && !this.condition(props)) return null;
|
||||||
|
|
||||||
@ -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 getDataSourcesModule from './getDataSourcesModule.js';
|
||||||
import getLayoutTemplateModule from './getLayoutTemplateModule.js';
|
import getLayoutTemplateModule from './getLayoutTemplateModule.js';
|
||||||
import getPanelModule from './getPanelModule';
|
import getPanelModule from './getPanelModule';
|
||||||
import getSopClassHandlerModule from './getSopClassHandlerModule.js';
|
import getSopClassHandlerModule from './getSopClassHandlerModule.js';
|
||||||
import getToolbarModule from './getToolbarModule';
|
import getToolbarModule from './getToolbarModule';
|
||||||
import commandsModule from './commandsModule';
|
import getCommandsModule from './commandsModule';
|
||||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||||
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
|
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
|
||||||
import getCustomizationModule from './getCustomizationModule';
|
import getCustomizationModule from './getCustomizationModule';
|
||||||
import { id } from './id.js';
|
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.
|
* Only required property. Should be a unique value across all extensions.
|
||||||
*/
|
*/
|
||||||
id,
|
id,
|
||||||
preRegistration: ({ servicesManager, configuration = {} }) => {
|
preRegistration,
|
||||||
init({ servicesManager, configuration });
|
|
||||||
},
|
|
||||||
getDataSourcesModule,
|
getDataSourcesModule,
|
||||||
getLayoutTemplateModule,
|
getLayoutTemplateModule,
|
||||||
getPanelModule,
|
getPanelModule,
|
||||||
getHangingProtocolModule,
|
getHangingProtocolModule,
|
||||||
getSopClassHandlerModule,
|
getSopClassHandlerModule,
|
||||||
getToolbarModule,
|
getToolbarModule,
|
||||||
getCommandsModule({ servicesManager, commandsManager }) {
|
getCommandsModule,
|
||||||
return commandsModule({ servicesManager, commandsManager });
|
|
||||||
},
|
|
||||||
getUtilityModule({ servicesManager }) {
|
getUtilityModule({ servicesManager }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -42,3 +44,5 @@ const defaultExtension = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defaultExtension;
|
export default defaultExtension;
|
||||||
|
|
||||||
|
export { ContextMenuController, CustomizeableContextMenuTypes };
|
||||||
@ -10,7 +10,7 @@ const metadataProvider = classes.MetadataProvider;
|
|||||||
* @param {Object} servicesManager
|
* @param {Object} servicesManager
|
||||||
* @param {Object} configuration
|
* @param {Object} configuration
|
||||||
*/
|
*/
|
||||||
export default function init({ servicesManager, configuration }) {
|
export default function init({ servicesManager, configuration = {} }): void {
|
||||||
const { stateSyncService } = servicesManager.services;
|
const { stateSyncService } = servicesManager.services;
|
||||||
// Add
|
// Add
|
||||||
DicomMetadataStore.subscribe(
|
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 };
|
||||||
14
extensions/test-extension/src/getCustomizationModule.ts
Normal file
14
extensions/test-extension/src/getCustomizationModule.ts
Normal 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],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { Types } from '@ohif/core';
|
|||||||
import { id } from './id';
|
import { id } from './id';
|
||||||
|
|
||||||
import getHangingProtocolModule from './hp';
|
import getHangingProtocolModule from './hp';
|
||||||
|
import getCustomizationModule from './getCustomizationModule';
|
||||||
// import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan';
|
// import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan';
|
||||||
import sameAs from './custom-attribute/sameAs';
|
import sameAs from './custom-attribute/sameAs';
|
||||||
import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets';
|
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 */
|
/** Registers some additional hanging protocols. See hp/index.tsx for more details */
|
||||||
getHangingProtocolModule,
|
getHangingProtocolModule,
|
||||||
|
|
||||||
|
/** Registers some customizations */
|
||||||
|
getCustomizationModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default testExtension;
|
export default testExtension;
|
||||||
|
|||||||
@ -72,6 +72,7 @@ function modeFactory() {
|
|||||||
measurementService,
|
measurementService,
|
||||||
toolbarService,
|
toolbarService,
|
||||||
toolGroupService,
|
toolGroupService,
|
||||||
|
customizationService,
|
||||||
} = servicesManager.services;
|
} = servicesManager.services;
|
||||||
|
|
||||||
measurementService.clearMeasurements();
|
measurementService.clearMeasurements();
|
||||||
@ -79,6 +80,12 @@ function modeFactory() {
|
|||||||
// Init Default and SR ToolGroups
|
// Init Default and SR ToolGroups
|
||||||
initToolGroups(extensionManager, toolGroupService, commandsManager);
|
initToolGroups(extensionManager, toolGroupService, commandsManager);
|
||||||
|
|
||||||
|
// init customizations
|
||||||
|
console.log('* Adding mode customizations');
|
||||||
|
customizationService.addModeCustomizations([
|
||||||
|
'@ohif/extension-test.customizationModule.custom-context-menu',
|
||||||
|
]);
|
||||||
|
|
||||||
let unsubscribe;
|
let unsubscribe;
|
||||||
|
|
||||||
const activateTool = () => {
|
const activateTool = () => {
|
||||||
|
|||||||
@ -106,7 +106,7 @@ export class CommandsManager {
|
|||||||
* @param {String} commandName - Command to find
|
* @param {String} commandName - Command to find
|
||||||
* @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts
|
* @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts
|
||||||
*/
|
*/
|
||||||
getCommand = (commandName, contextName) => {
|
getCommand = (commandName: string, contextName?: string) => {
|
||||||
const contexts = [];
|
const contexts = [];
|
||||||
|
|
||||||
if (contextName) {
|
if (contextName) {
|
||||||
|
|||||||
@ -36,7 +36,10 @@ export interface ExtensionParams extends ExtensionConstructor {
|
|||||||
*/
|
*/
|
||||||
export interface Extension {
|
export interface Extension {
|
||||||
id: string;
|
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 = {
|
export type ExtensionRegister = {
|
||||||
@ -44,6 +47,12 @@ export type ExtensionRegister = {
|
|||||||
create: (p: ExtensionParams) => Extension;
|
create: (p: ExtensionParams) => Extension;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CommandsModule = {
|
||||||
|
actions: Record<string, unknown>;
|
||||||
|
definitions: Record<string, unknown>;
|
||||||
|
defaultContext?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default class ExtensionManager {
|
export default class ExtensionManager {
|
||||||
private _commandsManager: CommandsManager;
|
private _commandsManager: CommandsManager;
|
||||||
private _servicesManager: ServicesManager;
|
private _servicesManager: ServicesManager;
|
||||||
@ -331,7 +340,7 @@ export default class ExtensionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extensionModule = getModuleFn({
|
const extensionModule = extension[getModuleFnName]({
|
||||||
appConfig: this._appConfig,
|
appConfig: this._appConfig,
|
||||||
commandsManager: this._commandsManager,
|
commandsManager: this._commandsManager,
|
||||||
servicesManager: this._servicesManager,
|
servicesManager: this._servicesManager,
|
||||||
@ -348,6 +357,7 @@ export default class ExtensionManager {
|
|||||||
|
|
||||||
return extensionModule;
|
return extensionModule;
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
console.log(ex);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Exception thrown while trying to call ${getModuleFnName} for the ${extensionId} extension`
|
`Exception thrown while trying to call ${getModuleFnName} for the ${extensionId} extension`
|
||||||
);
|
);
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export default class CustomizationService extends PubSubService {
|
|||||||
* may have been extended with any customizationType extensions provided,
|
* may have been extended with any customizationType extensions provided,
|
||||||
* so you cannot just use `|| defaultValue`
|
* so you cannot just use `|| defaultValue`
|
||||||
* @return A customization to use if one is found, or the default customization,
|
* @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(
|
public getCustomization(
|
||||||
customizationId: string,
|
customizationId: string,
|
||||||
@ -145,7 +145,7 @@ export default class CustomizationService extends PubSubService {
|
|||||||
this.globalCustomizations[customizationId] ??
|
this.globalCustomizations[customizationId] ??
|
||||||
this.modeCustomizations[customizationId] ??
|
this.modeCustomizations[customizationId] ??
|
||||||
defaultValue;
|
defaultValue;
|
||||||
return this.applyType(customization);
|
return this.transform(customization);
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasModeCustomization(customizationId: string) {
|
public hasModeCustomization(customizationId: string) {
|
||||||
@ -154,6 +154,16 @@ export default class CustomizationService extends PubSubService {
|
|||||||
this.modeCustomizations[customizationId]
|
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.
|
* 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
|
* and if that is found, will assign all iterable values from that
|
||||||
* type into the new type, allowing default behaviour to be configured.
|
* 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;
|
if (!customization) return customization;
|
||||||
const { customizationType } = customization;
|
const { customizationType } = customization;
|
||||||
if (!customizationType) return customization;
|
if (!customizationType) return customization;
|
||||||
const parent = this.getCustomization(customizationType);
|
const parent = this.getCustomization(customizationType);
|
||||||
return parent
|
const result = parent
|
||||||
? Object.assign(Object.create(parent), customization)
|
? Object.assign(Object.create(parent), customization)
|
||||||
: customization;
|
: customization;
|
||||||
|
// Execute an nested type information
|
||||||
|
return result.transform?.(this) || result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public addModeCustomizations(modeCustomizations): void {
|
public addModeCustomizations(modeCustomizations): void {
|
||||||
@ -195,7 +207,7 @@ export default class CustomizationService extends PubSubService {
|
|||||||
id: string,
|
id: string,
|
||||||
defaultValue?: Customization
|
defaultValue?: Customization
|
||||||
): Customization | void {
|
): Customization | void {
|
||||||
return this.applyType(this.globalCustomizations[id] ?? defaultValue);
|
return this.transform(this.globalCustomizations[id] ?? defaultValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
setGlobalCustomization(id: string, value: Customization): void {
|
setGlobalCustomization(id: string, value: Customization): void {
|
||||||
@ -235,7 +247,7 @@ export default class CustomizationService extends PubSubService {
|
|||||||
const extensionValue = this.findExtensionValue(value);
|
const extensionValue = this.findExtensionValue(value);
|
||||||
// The child of a reference is only a set of references when an array,
|
// 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
|
// 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)) {
|
} else if (Array.isArray(value)) {
|
||||||
this.addReferences(value, isGlobal);
|
this.addReferences(value, isGlobal);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -4,9 +4,7 @@ export interface Command {
|
|||||||
context?: string;
|
context?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A set of commands, typically contained in a tool item or other configuration */
|
||||||
* This is the format used within many items for multiple commands
|
|
||||||
*/
|
|
||||||
export interface Commands {
|
export interface Commands {
|
||||||
commands: [];
|
commands: Commands[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -210,8 +210,8 @@ example (this example comes from the context menu customizations as that one
|
|||||||
uses commands lists):
|
uses commands lists):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
cornerstoneContextMenu = uiConfigurationService.getModeCustomization("cornerstoneContextMenu", defaultMenu);
|
cornerstoneContextMenu = uiConfigurationService.get("cornerstoneContextMenu", defaultMenu);
|
||||||
uiConfigurationService.recordInteraction(cornerstoneContextMenu, extraProps);
|
commandsManager.run(cornerstoneContextMenu, extraProps);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Global Customizations
|
### Global Customizations
|
||||||
@ -229,7 +229,7 @@ This allows for having strong typing when declaring customizations, for example:
|
|||||||
```ts
|
```ts
|
||||||
import { Types } from '@ohif/ui';
|
import { Types } from '@ohif/ui';
|
||||||
|
|
||||||
const customContextMenu: Types.UIContextMenu =
|
const customContextMenu: Types.ContextMenu.Menu =
|
||||||
{
|
{
|
||||||
id: 'cornerstoneContextMenu',
|
id: 'cornerstoneContextMenu',
|
||||||
customizationType: 'ohif.contextMenu',
|
customizationType: 'ohif.contextMenu',
|
||||||
@ -260,7 +260,7 @@ getCustomizationModule = () => ([
|
|||||||
```
|
```
|
||||||
|
|
||||||
defines an overlay item which has a React content object as the render value.
|
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
|
```js
|
||||||
const overlayItem: Types.UIOverlayItem = {
|
const overlayItem: Types.UIOverlayItem = {
|
||||||
@ -275,7 +275,6 @@ const overlayItem: Types.UIOverlayItem = {
|
|||||||
|
|
||||||
This section can be used to specify various customization capabilities.
|
This section can be used to specify various customization capabilities.
|
||||||
|
|
||||||
|
|
||||||
## Text color for StudyBrowser tabs
|
## Text color for StudyBrowser tabs
|
||||||
|
|
||||||
This is the recommended pattern for deep customization of class attributes,
|
This is the recommended pattern for deep customization of class attributes,
|
||||||
@ -478,6 +477,29 @@ window.config = {
|
|||||||
|
|
||||||
<img src="../../../assets/img/customizable-overlay.png" />
|
<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.
|
> 3rd Party implementers may be added to this table via pull requests.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@ -1,20 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-cy="context-menu"
|
||||||
className="relative bg-secondary-dark rounded z-50 block w-48"
|
className="relative bg-secondary-dark rounded z-50 block w-48"
|
||||||
onContextMenu={e => e.preventDefault()}
|
onContextMenu={e => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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"
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -25,7 +37,6 @@ ContextMenu.propTypes = {
|
|||||||
items: PropTypes.arrayOf(
|
items: PropTypes.arrayOf(
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
actionType: PropTypes.string.isRequired,
|
|
||||||
action: PropTypes.func.isRequired,
|
action: PropTypes.func.isRequired,
|
||||||
})
|
})
|
||||||
).isRequired,
|
).isRequired,
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from './ContextMenuMeasurements';
|
|
||||||
@ -1,36 +1,52 @@
|
|||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { NavBar, Svg, Icon, IconButton, Dropdown } from '../';
|
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');
|
const { t } = useTranslation('Header');
|
||||||
|
|
||||||
// TODO: this should be passed in as a prop instead and the react-router-dom
|
// TODO: this should be passed in as a prop instead and the react-router-dom
|
||||||
// dependency should be dropped
|
// dependency should be dropped
|
||||||
const onClickReturn = () => {
|
const onClickReturn = () => {
|
||||||
if (isReturnEnabled && onClickReturnButton) {
|
if (isReturnEnabled && onClickReturnButton) {
|
||||||
onClickReturnButton()
|
onClickReturnButton();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomLogo = (React) => {
|
|
||||||
return WhiteLabeling.createLogoComponentFn(React)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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 justify-between flex-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* // TODO: Should preserve filter/sort
|
{/* // TODO: Should preserve filter/sort
|
||||||
// Either injected service? Or context (like react router's `useLocation`?) */}
|
// Either injected service? Or context (like react router's `useLocation`?) */}
|
||||||
<div
|
<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}
|
onClick={onClickReturn}
|
||||||
>
|
>
|
||||||
{isReturnEnabled && <Icon name="chevron-left" className="w-8 text-primary-active" />}
|
{isReturnEnabled && (
|
||||||
<div className="ml-4">{WhiteLabeling ? CustomLogo(React) : <Svg name="logo-ohif" />}</div>
|
<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>
|
</div>
|
||||||
<div className="flex items-center">{children}</div>
|
<div className="flex items-center">{children}</div>
|
||||||
@ -40,7 +56,7 @@ function Header({ children, menuOptions, isReturnEnabled, onClickReturnButton, i
|
|||||||
</span>
|
</span>
|
||||||
<Dropdown id="options" showDropdownIcon={false} list={menuOptions}>
|
<Dropdown id="options" showDropdownIcon={false} list={menuOptions}>
|
||||||
<IconButton
|
<IconButton
|
||||||
id={"options-settings-icon"}
|
id={'options-settings-icon'}
|
||||||
variant="text"
|
variant="text"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
size="initial"
|
size="initial"
|
||||||
@ -49,7 +65,7 @@ function Header({ children, menuOptions, isReturnEnabled, onClickReturnButton, i
|
|||||||
<Icon name="settings" />
|
<Icon name="settings" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
id={"options-chevron-down-icon"}
|
id={'options-chevron-down-icon'}
|
||||||
variant="text"
|
variant="text"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
size="initial"
|
size="initial"
|
||||||
@ -76,12 +92,12 @@ Header.propTypes = {
|
|||||||
isReturnEnabled: PropTypes.bool,
|
isReturnEnabled: PropTypes.bool,
|
||||||
isSticky: PropTypes.bool,
|
isSticky: PropTypes.bool,
|
||||||
onClickReturnButton: PropTypes.func,
|
onClickReturnButton: PropTypes.func,
|
||||||
WhiteLabeling: PropTypes.element,
|
WhiteLabeling: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
Header.defaultProps = {
|
Header.defaultProps = {
|
||||||
isReturnEnabled: true,
|
isReturnEnabled: true,
|
||||||
isSticky: false
|
isSticky: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|||||||
@ -48,7 +48,6 @@ import ThumbnailNoImage from './ThumbnailNoImage';
|
|||||||
import ThumbnailTracked from './ThumbnailTracked';
|
import ThumbnailTracked from './ThumbnailTracked';
|
||||||
import ThumbnailList from './ThumbnailList';
|
import ThumbnailList from './ThumbnailList';
|
||||||
import ToolbarButton from './ToolbarButton';
|
import ToolbarButton from './ToolbarButton';
|
||||||
import ContextMenuMeasurements from './ContextMenuMeasurements';
|
|
||||||
import ExpandableToolbarButton from './ExpandableToolbarButton';
|
import ExpandableToolbarButton from './ExpandableToolbarButton';
|
||||||
import ListMenu from './ListMenu';
|
import ListMenu from './ListMenu';
|
||||||
import Tooltip from './Tooltip';
|
import Tooltip from './Tooltip';
|
||||||
@ -134,7 +133,6 @@ export {
|
|||||||
ThumbnailTracked,
|
ThumbnailTracked,
|
||||||
ThumbnailList,
|
ThumbnailList,
|
||||||
ToolbarButton,
|
ToolbarButton,
|
||||||
ContextMenuMeasurements,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipClipboard,
|
TooltipClipboard,
|
||||||
Typography,
|
Typography,
|
||||||
|
|||||||
@ -94,7 +94,6 @@ export {
|
|||||||
ThumbnailTracked,
|
ThumbnailTracked,
|
||||||
ThumbnailList,
|
ThumbnailList,
|
||||||
ToolbarButton,
|
ToolbarButton,
|
||||||
ContextMenuMeasurements,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipClipboard,
|
TooltipClipboard,
|
||||||
Typography,
|
Typography,
|
||||||
|
|||||||
7
platform/ui/src/types/ContextMenuItem.ts
Normal file
7
platform/ui/src/types/ContextMenuItem.ts
Normal 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;
|
||||||
|
};
|
||||||
1
platform/ui/src/types/Predicate.ts
Normal file
1
platform/ui/src/types/Predicate.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type Predicate = (props: Record<string, unknown>) => boolean;
|
||||||
@ -4,6 +4,9 @@ import { PresentationIds } from '../contextProviders/getPresentationIds';
|
|||||||
|
|
||||||
// A few miscellaneous types declared inline here.
|
// A few miscellaneous types declared inline here.
|
||||||
|
|
||||||
|
export * from './Predicate';
|
||||||
|
export * from './ContextMenuItem';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StringNumber often comes back from DICOMweb for integer valued items.
|
* StringNumber often comes back from DICOMweb for integer valued items.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -52,7 +52,7 @@ describe('OHIF Study Viewer Page', function() {
|
|||||||
|
|
||||||
it('performs double-click to load thumbnail in active viewport', () => {
|
it('performs double-click to load thumbnail in active viewport', () => {
|
||||||
// Have to finish rendering the image before this works
|
// 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('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
|
||||||
|
|
||||||
//cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText);
|
//cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user