ohif-viewer/extensions/default/src/commandsModule.ts

616 lines
22 KiB
TypeScript

import { Types } from '@ohif/core';
import { ContextMenuController, defaultContextMenu } from './CustomizableContextMenu';
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
import reuseCachedLayouts from './utils/reuseCachedLayouts';
import findViewportsByPosition, {
findOrCreateViewport as layoutFindOrCreate,
} from './findViewportsByPosition';
import { ContextMenuProps } from './CustomizableContextMenu/types';
import { NavigateHistory } from './types/commandModuleTypes';
import { history } from '@ohif/app';
import { useViewportGridStore } from './stores/useViewportGridStore';
import { useDisplaySetSelectorStore } from './stores/useDisplaySetSelectorStore';
import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolStageIndexStore';
import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore';
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
export type HangingProtocolParams = {
protocolId?: string;
stageIndex?: number;
activeStudyUID?: string;
stageId?: string;
reset?: false;
};
export type UpdateViewportDisplaySetParams = {
direction: number;
excludeNonImageModalities?: boolean;
};
const commandsModule = ({
servicesManager,
commandsManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const {
customizationService,
measurementService,
hangingProtocolService,
uiNotificationService,
viewportGridService,
displaySetService,
} = servicesManager.services;
// Define a context menu controller for use with any context menus
const contextMenuController = new ContextMenuController(servicesManager, commandsManager);
const actions = {
/**
* Show the context menu.
* @param options.menuId defines the menu name to lookup, from customizationService
* @param options.defaultMenu contains the default menu set to use
* @param options.element is the element to show the menu within
* @param options.event is the event that caused the context menu
* @param options.selectorProps is the set of selection properties to use
*/
showContextMenu: (options: ContextMenuProps) => {
const {
menuCustomizationId,
element,
event,
selectorProps,
defaultPointsPosition = [],
} = options;
const optionsToUse = { ...options };
if (menuCustomizationId) {
Object.assign(
optionsToUse,
customizationService.get(menuCustomizationId, defaultContextMenu)
);
}
// TODO - make the selectorProps richer by including the study metadata and display set.
const { protocol, stage } = hangingProtocolService.getActiveProtocol();
optionsToUse.selectorProps = {
event,
protocol,
stage,
...selectorProps,
};
contextMenuController.showContextMenu(optionsToUse, element, defaultPointsPosition);
},
/** Close a context menu currently displayed */
closeContextMenu: () => {
contextMenuController.closeContextMenu();
},
displayNotification: ({ text, title, type }) => {
uiNotificationService.show({
title: title,
message: text,
type: type,
});
},
clearMeasurements: () => {
measurementService.clearMeasurements();
},
/**
* Sets the specified protocol
* 1. Records any existing state using the viewport grid service
* 2. Finds the destination state - this can be one of:
* a. The specified protocol stage
* b. An alternate (toggled or restored) protocol stage
* c. A restored custom layout
* 3. Finds the parameters for the specified state
* a. Gets the displaySetSelectorMap
* b. Gets the map by position
* c. Gets any toggle mapping to map position to/from current view
* 4. If restore, then sets layout
* a. Maps viewport position by currently displayed viewport map id
* b. Uses toggle information to map display set id
* 5. Else applies the hanging protocol
* a. HP Service is provided displaySetSelectorMap
* b. HP Service will throw an exception if it isn't applicable
* @param options - contains information on the HP to apply
* @param options.activeStudyUID - the updated study to apply the HP to
* @param options.protocolId - the protocol ID to change to
* @param options.stageId - the stageId to apply
* @param options.stageIndex - the index of the stage to go to.
* @param options.reset - flag to indicate if the HP should be reset to its original and not restored to a previous state
*
* commandsManager.run('setHangingProtocol', {
* activeStudyUID: '1.2.3',
* protocolId: 'myProtocol',
* stageId: 'myStage',
* stageIndex: 0,
* reset: false,
* });
*/
setHangingProtocol: ({
activeStudyUID = '',
protocolId,
stageId,
stageIndex,
reset = false,
}: HangingProtocolParams): boolean => {
try {
// Stores in the state the display set selector id to displaySetUID mapping
// Pass in viewportId for the active viewport. This item will get set as
// the activeViewportId
const state = viewportGridService.getState();
const hpInfo = hangingProtocolService.getState();
reuseCachedLayouts(state, hangingProtocolService);
const { hangingProtocolStageIndexMap } = useHangingProtocolStageIndexStore.getState();
const { displaySetSelectorMap } = useDisplaySetSelectorStore.getState();
if (!protocolId) {
// Reuse the previous protocol id, and optionally stage
protocolId = hpInfo.protocolId;
if (stageId === undefined && stageIndex === undefined) {
stageIndex = hpInfo.stageIndex;
}
} else if (stageIndex === undefined && stageId === undefined) {
// Re-set the same stage as was previously used
const hangingId = `${activeStudyUID || hpInfo.activeStudyUID}:${protocolId}`;
stageIndex = hangingProtocolStageIndexMap[hangingId]?.stageIndex;
}
const useStageIdx =
stageIndex ??
hangingProtocolService.getStageIndex(protocolId, {
stageId,
stageIndex,
});
if (activeStudyUID) {
hangingProtocolService.setActiveStudyUID(activeStudyUID);
}
const storedHanging = `${hangingProtocolService.getState().activeStudyUID}:${protocolId}:${
useStageIdx || 0
}`;
const { viewportGridState } = useViewportGridStore.getState();
const restoreProtocol = !reset && viewportGridState[storedHanging];
if (
protocolId === hpInfo.protocolId &&
useStageIdx === hpInfo.stageIndex &&
!activeStudyUID
) {
// Clear the HP setting to reset them
hangingProtocolService.setProtocol(protocolId, {
stageId,
stageIndex: useStageIdx,
});
} else {
hangingProtocolService.setProtocol(protocolId, {
displaySetSelectorMap,
stageId,
stageIndex: useStageIdx,
restoreProtocol,
});
if (restoreProtocol) {
viewportGridService.set(viewportGridState[storedHanging]);
}
}
// Do this after successfully applying the update
const { setDisplaySetSelector } = useDisplaySetSelectorStore.getState();
setDisplaySetSelector(
`${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0`,
null
);
return true;
} catch (e) {
console.error(e);
uiNotificationService.show({
title: 'Apply Hanging Protocol',
message: 'The hanging protocol could not be applied.',
type: 'error',
duration: 3000,
});
return false;
}
},
toggleHangingProtocol: ({ protocolId, stageIndex }: HangingProtocolParams): boolean => {
const {
protocol,
stageIndex: desiredStageIndex,
activeStudy,
} = hangingProtocolService.getActiveProtocol();
const { toggleHangingProtocol, setToggleHangingProtocol } =
useToggleHangingProtocolStore.getState();
const storedHanging = `${activeStudy.StudyInstanceUID}:${protocolId}:${stageIndex | 0}`;
if (
protocol.id === protocolId &&
(stageIndex === undefined || stageIndex === desiredStageIndex)
) {
// Toggling off - restore to previous state
const previousState = toggleHangingProtocol[storedHanging] || {
protocolId: 'default',
};
return actions.setHangingProtocol(previousState);
} else {
setToggleHangingProtocol(storedHanging, {
protocolId: protocol.id,
stageIndex: desiredStageIndex,
});
return actions.setHangingProtocol({
protocolId,
stageIndex,
reset: true,
});
}
},
deltaStage: ({ direction }) => {
const { protocolId, stageIndex: oldStageIndex } = hangingProtocolService.getState();
const { protocol } = hangingProtocolService.getActiveProtocol();
for (
let stageIndex = oldStageIndex + direction;
stageIndex >= 0 && stageIndex < protocol.stages.length;
stageIndex += direction
) {
if (protocol.stages[stageIndex].status !== 'disabled') {
return actions.setHangingProtocol({
protocolId,
stageIndex,
});
}
}
uiNotificationService.show({
title: 'Change Stage',
message: 'The hanging protocol has no more applicable stages',
type: 'info',
duration: 3000,
});
},
/**
* Changes the viewport grid layout in terms of the MxN layout.
*/
setViewportGridLayout: ({ numRows, numCols, isHangingProtocolLayout = false }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const onLayoutChange = protocol.callbacks?.onLayoutChange;
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) {
console.log('setViewportGridLayout running', onLayoutChange, numRows, numCols);
// Don't apply the layout if the run command returns false
return;
}
const completeLayout = () => {
const state = viewportGridService.getState();
findViewportsByPosition(state, { numRows, numCols });
const { viewportsByPosition, initialInDisplay } = useViewportsByPositionStore.getState();
const findOrCreateViewport = layoutFindOrCreate.bind(
null,
hangingProtocolService,
isHangingProtocolLayout,
{ ...viewportsByPosition, initialInDisplay }
);
viewportGridService.setLayout({
numRows,
numCols,
findOrCreateViewport,
isHangingProtocolLayout,
});
};
// Need to finish any work in the callback
window.setTimeout(completeLayout, 0);
},
toggleOneUp() {
const viewportGridState = viewportGridService.getState();
const { activeViewportId, viewports, layout, isHangingProtocolLayout } = viewportGridState;
const { displaySetInstanceUIDs, displaySetOptions, viewportOptions } =
viewports.get(activeViewportId);
if (layout.numCols === 1 && layout.numRows === 1) {
// The viewer is in one-up. Check if there is a state to restore/toggle back to.
const { toggleOneUpViewportGridStore } = useToggleOneUpViewportGridStore.getState();
if (!toggleOneUpViewportGridStore) {
return;
}
// There is a state to toggle back to. The viewport that was
// originally toggled to one up was the former active viewport.
const viewportIdToUpdate = toggleOneUpViewportGridStore.activeViewportId;
// We are restoring the previous layout but taking into the account that
// the current one up viewport might have a new displaySet dragged and dropped on it.
// updatedViewportsViaHP below contains the viewports applicable to the HP that existed
// prior to the toggle to one-up - including the updated viewports if a display
// set swap were to have occurred.
const updatedViewportsViaHP =
displaySetInstanceUIDs.length > 1
? []
: displaySetInstanceUIDs
.map(displaySetInstanceUID =>
hangingProtocolService.getViewportsRequireUpdate(
viewportIdToUpdate,
displaySetInstanceUID,
isHangingProtocolLayout
)
)
.flat();
// findOrCreateViewport returns either one of the updatedViewportsViaHP
// returned from the HP service OR if there is not one from the HP service then
// simply returns what was in the previous state for a given position in the layout.
const findOrCreateViewport = (position: number, positionId: string) => {
// Find the viewport for the given position prior to the toggle to one-up.
const preOneUpViewport = Array.from(toggleOneUpViewportGridStore.viewports.values()).find(
viewport => viewport.positionId === positionId
);
// Use the viewport id from before the toggle to one-up to find any updates to the viewport.
const viewport = updatedViewportsViaHP.find(
viewport => viewport.viewportId === preOneUpViewport.viewportId
);
return viewport
? // Use the applicable viewport from the HP updated viewports
{ viewportOptions, displaySetOptions, ...viewport }
: // Use the previous viewport for the given position
preOneUpViewport;
};
const layoutOptions = viewportGridService.getLayoutOptionsFromState(
toggleOneUpViewportGridStore
);
// Restore the previous layout including the active viewport.
viewportGridService.setLayout({
numRows: toggleOneUpViewportGridStore.layout.numRows,
numCols: toggleOneUpViewportGridStore.layout.numCols,
activeViewportId: viewportIdToUpdate,
layoutOptions,
findOrCreateViewport,
isHangingProtocolLayout: true,
});
// Reset crosshairs after restoring the layout
setTimeout(() => {
commandsManager.runCommand('resetCrosshairs');
}, 0);
} else {
// We are not in one-up, so toggle to one up.
// Store the current viewport grid state so we can toggle it back later.
const { setToggleOneUpViewportGridStore } = useToggleOneUpViewportGridStore.getState();
setToggleOneUpViewportGridStore(viewportGridState);
// one being toggled to one up.
const findOrCreateViewport = () => {
return {
displaySetInstanceUIDs,
displaySetOptions,
viewportOptions,
};
};
// Set the layout to be 1x1/one-up.
viewportGridService.setLayout({
numRows: 1,
numCols: 1,
findOrCreateViewport,
isHangingProtocolLayout: true,
});
}
},
/**
* Exposes the browser history navigation used by OHIF. This command can be used to either replace or
* push a new entry into the browser history. For example, the following will replace the current
* browser history entry with the specified relative URL which changes the study displayed to the
* study with study instance UID 1.2.3. Note that as a result of using `options.replace = true`, the
* page prior to invoking this command cannot be returned to via the browser back button.
*
* navigateHistory({
* to: 'viewer?StudyInstanceUIDs=1.2.3',
* options: { replace: true },
* });
*
* @param historyArgs - arguments for the history function;
* the `to` property is the URL;
* the `options.replace` is a boolean indicating if the current browser history entry
* should be replaced or a new entry pushed onto the history (stack); the default value
* for `replace` is false
*/
navigateHistory(historyArgs: NavigateHistory) {
history.navigate(historyArgs.to, historyArgs.options);
},
openDICOMTagViewer({ displaySetInstanceUID }: { displaySetInstanceUID?: string }) {
const { activeViewportId, viewports } = viewportGridService.getState();
const activeViewportSpecificData = viewports.get(activeViewportId);
const { displaySetInstanceUIDs } = activeViewportSpecificData;
const displaySets = displaySetService.activeDisplaySets;
const { UIModalService } = servicesManager.services;
const defaultDisplaySetInstanceUID = displaySetInstanceUID || displaySetInstanceUIDs[0];
UIModalService.show({
content: DicomTagBrowser,
contentProps: {
displaySets,
displaySetInstanceUID: defaultDisplaySetInstanceUID,
onClose: UIModalService.hide,
},
containerDimensions: 'w-[70%] max-w-[900px]',
title: 'DICOM Tag Browser',
});
},
/**
* Toggle viewport overlay (the information panel shown on the four corners
* of the viewport)
* @see ViewportOverlay and CustomizableViewportOverlay components
*/
toggleOverlays: () => {
const overlays = document.getElementsByClassName('viewport-overlay');
for (let i = 0; i < overlays.length; i++) {
overlays.item(i).classList.toggle('hidden');
}
},
scrollActiveThumbnailIntoView: () => {
const { activeViewportId, viewports } = viewportGridService.getState();
const activeViewport = viewports.get(activeViewportId);
const activeDisplaySetInstanceUID = activeViewport.displaySetInstanceUIDs[0];
const thumbnailList = document.querySelector('#ohif-thumbnail-list');
if (!thumbnailList) {
return;
}
const thumbnailListBounds = thumbnailList.getBoundingClientRect();
const thumbnail = document.querySelector(`#thumbnail-${activeDisplaySetInstanceUID}`);
if (!thumbnail) {
return;
}
const thumbnailBounds = thumbnail.getBoundingClientRect();
// This only handles a vertical thumbnail list.
if (
thumbnailBounds.top >= thumbnailListBounds.top &&
thumbnailBounds.top <= thumbnailListBounds.bottom
) {
return;
}
thumbnail.scrollIntoView({ behavior: 'smooth' });
},
updateViewportDisplaySet: ({
direction,
excludeNonImageModalities,
}: UpdateViewportDisplaySetParams) => {
const nonImageModalities = ['SR', 'SEG', 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE'];
const currentDisplaySets = [...displaySetService.activeDisplaySets];
const { activeViewportId, viewports, isHangingProtocolLayout } =
viewportGridService.getState();
const { displaySetInstanceUIDs } = viewports.get(activeViewportId);
const activeDisplaySetIndex = currentDisplaySets.findIndex(displaySet =>
displaySetInstanceUIDs.includes(displaySet.displaySetInstanceUID)
);
let displaySetIndexToShow: number;
for (
displaySetIndexToShow = activeDisplaySetIndex + direction;
displaySetIndexToShow > -1 && displaySetIndexToShow < currentDisplaySets.length;
displaySetIndexToShow += direction
) {
if (
!excludeNonImageModalities ||
!nonImageModalities.includes(currentDisplaySets[displaySetIndexToShow].Modality)
) {
break;
}
}
if (displaySetIndexToShow < 0 || displaySetIndexToShow >= currentDisplaySets.length) {
return;
}
const { displaySetInstanceUID } = currentDisplaySets[displaySetIndexToShow];
let updatedViewports = [];
try {
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
activeViewportId,
displaySetInstanceUID,
isHangingProtocolLayout
);
} catch (error) {
console.warn(error);
uiNotificationService.show({
title: 'Navigate Viewport Display Set',
message:
'The requested display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
type: 'info',
duration: 3000,
});
}
viewportGridService.setDisplaySetsForViewports(updatedViewports);
setTimeout(() => actions.scrollActiveThumbnailIntoView(), 0);
},
};
const definitions = {
showContextMenu: {
commandFn: actions.showContextMenu,
},
closeContextMenu: {
commandFn: actions.closeContextMenu,
},
clearMeasurements: {
commandFn: actions.clearMeasurements,
},
displayNotification: {
commandFn: actions.displayNotification,
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
},
toggleHangingProtocol: {
commandFn: actions.toggleHangingProtocol,
},
navigateHistory: {
commandFn: actions.navigateHistory,
},
nextStage: {
commandFn: actions.deltaStage,
options: { direction: 1 },
},
previousStage: {
commandFn: actions.deltaStage,
options: { direction: -1 },
},
setViewportGridLayout: {
commandFn: actions.setViewportGridLayout,
},
toggleOneUp: {
commandFn: actions.toggleOneUp,
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
updateViewportDisplaySet: {
commandFn: actions.updateViewportDisplaySet,
},
};
return {
actions,
definitions,
defaultContext: 'DEFAULT',
};
};
export default commandsModule;