feat(toolbar): new Toolbar to enable reactive state synchronization (#3983)

This commit is contained in:
Alireza authored and GitHub committed 2024-03-27 16:01:32 -04:00
1 parent 79d5c36bda
commit 566b25a544
155 files changed
+6311 -6577

No files matched your search

@@ -5,6 +5,7 @@ import { cache, metaData } from '@cornerstonejs/core';
import {
segmentation as cornerstoneToolsSegmentation,
Enums as cornerstoneToolsEnums,
utilities,
} from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { classes, DicomMetadataStore } from '@ohif/core';
@@ -18,6 +19,7 @@ import {
getUpdatedViewportsForSegmentation,
getTargetViewport,
} from './utils/hydrationUtils';
const { segmentation: segmentationUtils } = utilities;
const { datasetToBlob } = dcmjs.data;
@@ -45,6 +47,7 @@ const commandsModule = ({
uiDialogService,
displaySetService,
viewportGridService,
toolGroupService,
} = (servicesManager as ServicesManager).services;
const actions = {
@@ -397,6 +400,60 @@ const commandsModule = ({
console.warn(e);
}
},
setBrushSize: ({ value, toolNames }) => {
const brushSize = Number(value);
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
if (toolNames?.length === 0) {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize);
} else {
toolNames?.forEach(toolName => {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize, toolName);
});
}
});
},
setThresholdRange: ({
value,
toolNames = ['ThresholdCircularBrush', 'ThresholdSphereBrush'],
}) => {
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
toolNames?.forEach(toolName => {
toolGroup.setToolConfiguration(toolName, {
strategySpecificConfiguration: {
THRESHOLD: {
threshold: value,
},
},
});
});
});
},
toggleThresholdRangeAndDynamic() {
const toolGroupIds = toolGroupService.getToolGroupIds();
if (!toolGroupIds) {
return;
}
toolGroupIds.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
const brushInstances = segmentationUtils.getBrushToolInstances(toolGroup.id);
brushInstances.forEach(({ configuration }) => {
const { activeStrategy, strategySpecificConfiguration } = configuration;
if (activeStrategy.startsWith('THRESHOLD')) {
const thresholdConfig = strategySpecificConfiguration.THRESHOLD;
if (thresholdConfig) {
thresholdConfig.isDynamic = !thresholdConfig.isDynamic;
}
}
});
});
},
};
const definitions = {
@@ -424,11 +481,21 @@ const commandsModule = ({
downloadRTSS: {
commandFn: actions.downloadRTSS,
},
setBrushSize: {
commandFn: actions.setBrushSize,
},
setThresholdRange: {
commandFn: actions.setThresholdRange,
},
toggleThresholdRangeAndDynamic: {
commandFn: actions.toggleThresholdRangeAndDynamic,
},
};
return {
actions,
definitions,
defaultContext: 'SEGMENTATION',
};
};
@@ -1,10 +1,16 @@
import React from 'react';
import { useAppConfig } from '@state';
import { Toolbox } from '@ohif/ui';
import PanelSegmentation from './panels/PanelSegmentation';
import SegmentationToolbox from './panels/SegmentationToolbox';
const getPanelModule = ({ commandsManager, servicesManager, extensionManager, configuration }) => {
const getPanelModule = ({
commandsManager,
servicesManager,
extensionManager,
configuration,
title,
}) => {
const { customizationService } = servicesManager.services;
const wrappedPanelSegmentation = configuration => {
@@ -26,13 +32,14 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager, co
};
const wrappedPanelSegmentationWithTools = configuration => {
const [appConfig] = useAppConfig();
return (
<>
<SegmentationToolbox
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="segmentationToolbox"
title="Segmentation Tools"
configuration={{
...configuration,
}}
@@ -0,0 +1,65 @@
export function getToolbarModule({ commandsManager, servicesManager }) {
const { segmentationService, toolGroupService } = servicesManager.services;
return [
{
name: 'evaluate.cornerstone.segmentation',
evaluate: ({ viewportId, button, toolNames }) => {
// Todo: we need to pass in the button section Id since we are kind of
// forcing the button to have black background since initially
// it is designed for the toolbox not the toolbar on top
// we should then branch the buttonSectionId to have different styles
const segmentations = segmentationService.getSegmentations();
if (!segmentations?.length) {
return {
disabled: true,
className: '!text-common-bright !bg-black opacity-50',
};
}
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
const toolName = getToolNameForButton(button);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
};
}
const isPrimaryActive = toolNames
? toolNames.includes(toolGroup.getActivePrimaryMouseButtonTool())
: toolGroup.getActivePrimaryMouseButtonTool() === toolName;
return {
disabled: false,
className: isPrimaryActive
? '!text-black !bg-primary-light hover:bg-primary-light hover-text-black hover:cursor-pointer'
: '!text-common-bright !bg-black hover:bg-primary-light hover:cursor-pointer hover:text-black',
// Todo: isActive right now is used for nested buttons where the primary
// button needs to be fully rounded (vs partial rounded) when active
// otherwise it does not have any other use
isActive: isPrimaryActive,
};
},
},
];
}
function getToolNameForButton(button) {
const { props } = button;
const commands = props?.commands || button.commands;
if (commands && commands.length) {
const command = commands[0];
const { commandOptions } = command;
const { toolName } = commandOptions || { toolName: props?.id ?? button.id };
return toolName;
}
return null;
}
@@ -5,6 +5,7 @@ import getSopClassHandlerModule from './getSopClassHandlerModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getPanelModule from './getPanelModule';
import getCommandsModule from './commandsModule';
import { getToolbarModule } from './getToolbarModule';
import preRegistration from './init';
const Component = React.lazy(() => {
@@ -29,7 +30,6 @@ const extension = {
*/
id,
preRegistration,
/**
* PanelModule should provide a list of panels that will be available in OHIF
* for Modes to consume and render. Each panel is defined by a {name,
@@ -38,8 +38,8 @@ const extension = {
*/
getPanelModule,
getCommandsModule,
getViewportModule({ servicesManager, extensionManager }) {
getToolbarModule,
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
const ExtendedOHIFCornerstoneSEGViewport = props => {
return (
<OHIFCornerstoneSEGViewport
+1 -1
View File
@@ -1,5 +1,5 @@
import { addTool, BrushTool } from '@cornerstonejs/tools';
export default function init({ configuration = {} }): void {
export default function init({ servicesManager }): void {
addTool(BrushTool);
}
@@ -1,425 +0,0 @@
import React, { useCallback, useEffect, useState, useReducer } from 'react';
import { AdvancedToolbox, InputDoubleRange, useViewportGrid } from '@ohif/ui';
import { Types } from '@ohif/extension-cornerstone';
import { utilities } from '@cornerstonejs/tools';
const { segmentation: segmentationUtils } = utilities;
const TOOL_TYPES = {
CIRCULAR_BRUSH: 'CircularBrush',
SPHERE_BRUSH: 'SphereBrush',
CIRCULAR_ERASER: 'CircularEraser',
SPHERE_ERASER: 'SphereEraser',
CIRCLE_SHAPE: 'CircleScissor',
RECTANGLE_SHAPE: 'RectangleScissor',
SPHERE_SHAPE: 'SphereScissor',
THRESHOLD_CIRCULAR_BRUSH: 'ThresholdCircularBrush',
THRESHOLD_SPHERE_BRUSH: 'ThresholdSphereBrush',
};
const ACTIONS = {
SET_TOOL_CONFIG: 'SET_TOOL_CONFIG',
SET_ACTIVE_TOOL: 'SET_ACTIVE_TOOL',
};
const initialState = {
Brush: {
brushSize: 15,
mode: 'CircularBrush', // Can be 'CircularBrush' or 'SphereBrush'
},
Eraser: {
brushSize: 15,
mode: 'CircularEraser', // Can be 'CircularEraser' or 'SphereEraser'
},
Shapes: {
brushSize: 15,
mode: 'CircleScissor', // E.g., 'CircleScissor', 'RectangleScissor', or 'SphereScissor'
},
ThresholdBrush: {
brushSize: 15,
thresholdRange: null,
},
activeTool: null,
};
function toolboxReducer(state, action) {
switch (action.type) {
case ACTIONS.SET_TOOL_CONFIG:
const { tool, config } = action.payload;
return {
...state,
[tool]: {
...state[tool],
...config,
},
};
case ACTIONS.SET_ACTIVE_TOOL:
return { ...state, activeTool: action.payload };
default:
return state;
}
}
function SegmentationToolbox({ servicesManager, extensionManager }) {
const { toolbarService, segmentationService, toolGroupService } =
servicesManager.services as Types.CornerstoneServices;
const [viewportGrid] = useViewportGrid();
const { viewports, activeViewportId } = viewportGrid;
const [toolsEnabled, setToolsEnabled] = useState(false);
const [state, dispatch] = useReducer(toolboxReducer, initialState);
const updateActiveTool = useCallback(() => {
if (!viewports?.size || activeViewportId === undefined) {
return;
}
const viewport = viewports.get(activeViewportId);
if (!viewport) {
return;
}
dispatch({
type: ACTIONS.SET_ACTIVE_TOOL,
payload: toolGroupService.getActiveToolForViewport(viewport.viewportId),
});
}, [activeViewportId, viewports, toolGroupService, dispatch]);
const setToolActive = useCallback(
toolName => {
initializeThresholdValue(toolName);
toolbarService.recordInteraction({
interactionType: 'tool',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName,
},
},
],
});
dispatch({ type: ACTIONS.SET_ACTIVE_TOOL, payload: toolName });
},
[toolbarService, dispatch]
);
/**
* sets the tools enabled IF there are segmentations
*/
useEffect(() => {
const events = [
segmentationService.EVENTS.SEGMENTATION_ADDED,
segmentationService.EVENTS.SEGMENTATION_UPDATED,
segmentationService.EVENTS.SEGMENTATION_REMOVED,
];
const unsubscriptions = [];
events.forEach(event => {
const { unsubscribe } = segmentationService.subscribe(event, () => {
const segmentations = segmentationService.getSegmentations();
const activeSegmentation = segmentations?.find(seg => seg.isActive);
setToolsEnabled(activeSegmentation?.segmentCount > 0);
});
unsubscriptions.push(unsubscribe);
});
updateActiveTool();
return () => {
unsubscriptions.forEach(unsubscribe => unsubscribe());
};
}, [activeViewportId, viewports, segmentationService, updateActiveTool]);
/**
* Update the active tool when the toolbar state changes
*/
useEffect(() => {
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => {
updateActiveTool();
}
);
return () => {
unsubscribe();
};
}, [toolbarService, updateActiveTool]);
useEffect(() => {
// if the active tool is not a brush tool then do nothing
if (!Object.values(TOOL_TYPES).includes(state.activeTool)) {
return;
}
// if the tool is Segmentation and it is enabled then do nothing
if (toolsEnabled) {
return;
}
// if the tool is Segmentation and it is disabled, then switch
// back to the window level tool to not confuse the user when no
// segmentation is active or when there is no segment in the segmentation
setToolActive('WindowLevel');
}, [toolsEnabled, state.activeTool, setToolActive]);
const updateBrushSize = useCallback(
(toolName, brushSize) => {
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize, toolName);
});
},
[toolGroupService]
);
function initializeThresholdValue(toolName: any) {
if (state.ThresholdBrush.thresholdRange === null) {
// set the default threshold range from the tool configuration
const toolGroupIds = toolGroupService.getToolGroupIds();
const toolGroupId = toolGroupIds[0];
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
const toolConfig = toolGroup.getToolConfiguration(toolName);
const defaultThresholdRange = toolConfig?.strategySpecificConfiguration?.THRESHOLD?.threshold;
dispatch({
type: ACTIONS.SET_TOOL_CONFIG,
payload: {
tool: 'ThresholdBrush',
config: { thresholdRange: defaultThresholdRange },
},
});
}
}
const onBrushSizeChange = useCallback(
(valueAsStringOrNumber, toolCategory) => {
const value = Number(valueAsStringOrNumber);
_getToolNamesFromCategory(toolCategory).forEach(toolName => {
updateBrushSize(toolName, value);
});
dispatch({
type: ACTIONS.SET_TOOL_CONFIG,
payload: {
tool: toolCategory,
config: { brushSize: value },
},
});
},
[toolGroupService, dispatch]
);
const handleRangeChange = useCallback(
newRange => {
if (
newRange[0] === state.ThresholdBrush.thresholdRange?.[0] &&
newRange[1] === state.ThresholdBrush.thresholdRange?.[1]
) {
return;
}
const toolNames = _getToolNamesFromCategory('ThresholdBrush');
toolNames.forEach(toolName => {
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
toolGroup.setToolConfiguration(toolName, {
strategySpecificConfiguration: {
THRESHOLD: {
threshold: newRange,
},
},
});
});
});
dispatch({
type: ACTIONS.SET_TOOL_CONFIG,
payload: {
tool: 'ThresholdBrush',
config: { thresholdRange: newRange },
},
});
},
[toolGroupService, dispatch, state.ThresholdBrush.thresholdRange]
);
return (
<AdvancedToolbox
title="Segmentation Tools"
items={[
{
name: 'Brush',
icon: 'icon-tool-brush',
disabled: !toolsEnabled,
active:
state.activeTool === TOOL_TYPES.CIRCULAR_BRUSH ||
state.activeTool === TOOL_TYPES.SPHERE_BRUSH,
onClick: () => setToolActive(TOOL_TYPES.CIRCULAR_BRUSH),
options: [
{
name: 'Radius (mm)',
id: 'brush-radius',
type: 'range',
min: 0.5,
max: 99.5,
value: state.Brush.brushSize,
step: 0.5,
onChange: value => onBrushSizeChange(value, 'Brush'),
},
{
name: 'Mode',
type: 'radio',
id: 'brush-mode',
value: state.Brush.mode,
values: [
{ value: TOOL_TYPES.CIRCULAR_BRUSH, label: 'Circle' },
{ value: TOOL_TYPES.SPHERE_BRUSH, label: 'Sphere' },
],
onChange: value => setToolActive(value),
},
],
},
{
name: 'Eraser',
icon: 'icon-tool-eraser',
disabled: !toolsEnabled,
active:
state.activeTool === TOOL_TYPES.CIRCULAR_ERASER ||
state.activeTool === TOOL_TYPES.SPHERE_ERASER,
onClick: () => setToolActive(TOOL_TYPES.CIRCULAR_ERASER),
options: [
{
name: 'Radius (mm)',
type: 'range',
id: 'eraser-radius',
min: 0.5,
max: 99.5,
value: state.Eraser.brushSize,
step: 0.5,
onChange: value => onBrushSizeChange(value, 'Eraser'),
},
{
name: 'Mode',
type: 'radio',
id: 'eraser-mode',
value: state.Eraser.mode,
values: [
{ value: TOOL_TYPES.CIRCULAR_ERASER, label: 'Circle' },
{ value: TOOL_TYPES.SPHERE_ERASER, label: 'Sphere' },
],
onChange: value => setToolActive(value),
},
],
},
{
name: 'Shapes',
icon: 'icon-tool-shape',
disabled: !toolsEnabled,
active:
state.activeTool === TOOL_TYPES.CIRCLE_SHAPE ||
state.activeTool === TOOL_TYPES.RECTANGLE_SHAPE ||
state.activeTool === TOOL_TYPES.SPHERE_SHAPE,
onClick: () => setToolActive(TOOL_TYPES.CIRCLE_SHAPE),
options: [
{
name: 'Mode',
type: 'radio',
value: state.Shapes.mode,
id: 'shape-mode',
values: [
{ value: TOOL_TYPES.CIRCLE_SHAPE, label: 'Circle' },
{ value: TOOL_TYPES.RECTANGLE_SHAPE, label: 'Rectangle' },
{ value: TOOL_TYPES.SPHERE_SHAPE, label: 'Sphere' },
],
onChange: value => setToolActive(value),
},
],
},
{
name: 'Threshold Tool',
icon: 'icon-tool-threshold',
disabled: !toolsEnabled,
active:
state.activeTool === TOOL_TYPES.THRESHOLD_CIRCULAR_BRUSH ||
state.activeTool === TOOL_TYPES.THRESHOLD_SPHERE_BRUSH,
onClick: () => setToolActive(TOOL_TYPES.THRESHOLD_CIRCULAR_BRUSH),
options: [
{
name: 'Radius (mm)',
id: 'threshold-radius',
type: 'range',
min: 0.5,
max: 99.5,
value: state.ThresholdBrush.brushSize,
step: 0.5,
onChange: value => onBrushSizeChange(value, 'ThresholdBrush'),
},
{
name: 'Mode',
type: 'radio',
id: 'threshold-mode',
value: state.activeTool,
values: [
{ value: TOOL_TYPES.THRESHOLD_CIRCULAR_BRUSH, label: 'Circle' },
{ value: TOOL_TYPES.THRESHOLD_SPHERE_BRUSH, label: 'Sphere' },
],
onChange: value => setToolActive(value),
},
{
type: 'custom',
id: 'segmentation-threshold-range',
children: () => {
return (
<div>
<div className="bg-secondary-light h-[1px]"></div>
<div className="mt-1 text-[13px] text-white">Threshold</div>
<InputDoubleRange
values={state.ThresholdBrush.thresholdRange}
onChange={handleRangeChange}
minValue={-1000} // Todo: these should be configurable
maxValue={1000}
step={1}
showLabel={true}
allowNumberEdit={true}
showAdjustmentArrows={false}
/>
</div>
);
},
},
],
},
]}
/>
);
}
function _getToolNamesFromCategory(category) {
let toolNames = [];
switch (category) {
case 'Brush':
toolNames = ['CircularBrush', 'SphereBrush'];
break;
case 'Eraser':
toolNames = ['CircularEraser', 'SphereEraser'];
break;
case 'ThresholdBrush':
toolNames = ['ThresholdCircularBrush', 'ThresholdSphereBrush'];
break;
default:
break;
}
return toolNames;
}
export default SegmentationToolbox;
@@ -128,13 +128,9 @@ const commandsModule = props => {
const definitions = {
downloadReport: {
commandFn: actions.downloadReport,
storeContexts: [],
options: {},
},
storeMeasurements: {
commandFn: actions.storeMeasurements,
storeContexts: [],
options: {},
},
};
+1
View File
@@ -39,6 +39,7 @@
"@cornerstonejs/codec-openjpeg": "^1.2.2",
"@cornerstonejs/codec-openjph": "^2.4.2",
"@cornerstonejs/dicom-image-loader": "^1.66.7",
"@icr/polyseg-wasm": "^0.4.0",
"@ohif/core": "3.8.0-beta.63",
"@ohif/ui": "3.8.0-beta.63",
"dcmjs": "^0.29.12",
@@ -109,6 +109,7 @@ const OHIFCornerstoneViewport = React.memo(props => {
// of the imageData in the OHIFCornerstoneViewport. This prop is used
// to set the initial state of the viewport's first image to render
initialImageIndex,
onReady,
} = props;
const viewportId = viewportOptions.viewportId;
@@ -184,6 +185,7 @@ const OHIFCornerstoneViewport = React.memo(props => {
if (onElementEnabled) {
onElementEnabled(evt);
onReady?.(evt);
}
},
[viewportId, onElementEnabled, toolGroupService]
@@ -2,21 +2,25 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { vec3 } from 'gl-matrix';
import PropTypes from 'prop-types';
import { metaData, Enums, utilities } from '@cornerstonejs/core';
import { ViewportOverlay } from '@ohif/ui';
import { formatPN, formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
import { InstanceMetadata } from 'platform/core/src/types';
import { ServicesManager } from '@ohif/core';
import { ImageSliceData } from '@cornerstonejs/core/dist/esm/types';
import { ViewportOverlay } from '@ohif/ui';
import { ServicesManager } from '@ohif/core';
import { InstanceMetadata } from '@ohif/core/src/types';
import { formatPN, formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
import './CustomizableViewportOverlay.css';
const EPSILON = 1e-4;
type ViewportData = StackViewportData | VolumeViewportData;
interface OverlayItemProps {
element: any;
viewportData: any;
element: HTMLElement;
viewportData: ViewportData;
imageSliceData: ImageSliceData;
servicesManager: ServicesManager;
viewportId: string;
instance: InstanceMetadata;
customization: any;
formatters: {
@@ -35,67 +39,11 @@ interface OverlayItemProps {
scale?: number;
}
/**
* Window Level / Center Overlay item
*/
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
const { windowWidth, windowCenter } = voi;
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
return null;
}
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">W:</span>
<span className="ml-1 mr-2 shrink-0 font-light">{windowWidth.toFixed(0)}</span>
<span className="mr-1 shrink-0">L:</span>
<span className="ml-1 shrink-0 font-light">{windowCenter.toFixed(0)}</span>
</div>
);
}
/**
* Zoom Level Overlay item
*/
function ZoomOverlayItem({ scale, customization }: OverlayItemProps) {
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">Zoom:</span>
<span className="font-light">{scale.toFixed(2)}x</span>
</div>
);
}
/**
* Instance Number Overlay Item
*/
function InstanceNumberOverlayItem({
instanceNumber,
imageSliceData,
customization,
}: OverlayItemProps) {
const { imageIndex, numberOfSlices } = imageSliceData;
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">I:</span>
<span className="font-light">
{instanceNumber !== undefined && instanceNumber !== null
? `${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`
: `${imageIndex + 1}/${numberOfSlices}`}
</span>
</div>
);
}
const OverlayItemComponents = {
'ohif.overlayItem.windowLevel': VOIOverlayItem,
'ohif.overlayItem.zoomLevel': ZoomOverlayItem,
'ohif.overlayItem.instanceNumber': InstanceNumberOverlayItem,
};
/**
* Customizable Viewport Overlay
@@ -106,12 +54,17 @@ function CustomizableViewportOverlay({
imageSliceData,
viewportId,
servicesManager,
}: {
element: HTMLElement;
viewportData: ViewportData;
imageSliceData: ImageSliceData;
viewportId: string;
servicesManager: ServicesManager;
}) {
const { toolbarService, cornerstoneViewportService, customizationService } =
const { cornerstoneViewportService, customizationService, toolGroupService } =
servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const [activeTools, setActiveTools] = useState([]);
const { imageIndex } = imageSliceData;
const topLeftCustomization = customizationService.getModeCustomization(
@@ -127,27 +80,18 @@ function CustomizableViewportOverlay({
'cornerstoneOverlayBottomRight'
);
const instance = useMemo(() => {
if (viewportData != null) {
return _getViewportInstance(viewportData, imageIndex);
} else {
return null;
}
}, [viewportData, imageIndex]);
const instance = useMemo(
() => (viewportData ? getViewportInstance(viewportData, imageIndex) : null),
[viewportData, imageIndex]
);
const instanceNumber = useMemo(() => {
if (viewportData != null) {
return _getInstanceNumber(viewportData, viewportId, imageIndex, cornerstoneViewportService);
}
return null;
}, [viewportData, viewportId, imageIndex, cornerstoneViewportService]);
/**
* Initial toolbar state
*/
useEffect(() => {
setActiveTools(toolbarService.getActiveTools());
}, []);
const instanceNumber = useMemo(
() =>
viewportData
? getInstanceNumber(viewportData, viewportId, imageIndex, cornerstoneViewportService)
: null,
[viewportData, viewportId, imageIndex, cornerstoneViewportService]
);
/**
* Updating the VOI when the viewport changes its voi
@@ -215,26 +159,9 @@ function CustomizableViewportOverlay({
};
}, [viewportId, viewportData, cornerstoneViewportService, element]);
/**
* Updating the active tools when the toolbar changes
*/
// Todo: this should act on the toolGroups instead of the toolbar state
useEffect(() => {
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => {
setActiveTools(toolbarService.getActiveTools());
}
);
return () => {
unsubscribe();
};
}, [toolbarService]);
const _renderOverlayItem = useCallback(
item => {
const overlayItemProps: OverlayItemProps = {
const overlayItemProps = {
element,
viewportData,
imageSliceData,
@@ -242,24 +169,26 @@ function CustomizableViewportOverlay({
servicesManager,
customization: item,
formatters: {
formatPN: formatPN,
formatPN,
formatDate: formatDICOMDate,
formatTime: formatDICOMTime,
formatNumberPrecision: formatNumberPrecision,
formatNumberPrecision,
},
instance,
// calculated
voi,
scale,
instanceNumber,
};
if (item.customizationType === 'ohif.overlayItem.windowLevel') {
return <VOIOverlayItem {...overlayItemProps} />;
} else if (item.customizationType === 'ohif.overlayItem.zoomLevel') {
return <ZoomOverlayItem {...overlayItemProps} />;
} else if (item.customizationType === 'ohif.overlayItem.instanceNumber') {
return <InstanceNumberOverlayItem {...overlayItemProps} />;
if (!item) {
return null;
}
const { customizationType } = item;
const OverlayItemComponent = OverlayItemComponents[customizationType];
if (OverlayItemComponent) {
return <OverlayItemComponent {...overlayItemProps} />;
} else {
const renderItem = customizationService.transform(item);
@@ -282,103 +211,106 @@ function CustomizableViewportOverlay({
]
);
const getTopLeftContent = useCallback(() => {
const items = topLeftCustomization?.items || [
{
id: 'WindowLevel',
customizationType: 'ohif.overlayItem.windowLevel',
},
];
return (
<>
{items.map((item, i) => (
<div key={`topLeftOverlayItem_${i}`}>{_renderOverlayItem(item)}</div>
))}
</>
);
}, [topLeftCustomization, _renderOverlayItem]);
const getContent = useCallback(
(customization, defaultItems, keyPrefix) => {
const items = customization?.items ?? defaultItems;
const getTopRightContent = useCallback(() => {
const items = topRightCustomization?.items || [
{
id: 'InstanceNmber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
];
return (
<>
{items.map((item, i) => (
<div key={`topRightOverlayItem_${i}`}>{_renderOverlayItem(item)}</div>
))}
</>
);
}, [topRightCustomization, _renderOverlayItem]);
const getBottomLeftContent = useCallback(() => {
const items = bottomLeftCustomization?.items || [];
return (
<>
{items.map((item, i) => (
<div key={`bottomLeftOverlayItem_${i}`}>{_renderOverlayItem(item)}</div>
))}
</>
);
}, [bottomLeftCustomization, _renderOverlayItem]);
const getBottomRightContent = useCallback(() => {
const items = bottomRightCustomization?.items || [];
return (
<>
{items.map((item, i) => (
<div key={`bottomRightOverlayItem_${i}`}>{_renderOverlayItem(item)}</div>
))}
</>
);
}, [bottomRightCustomization, _renderOverlayItem]);
return (
<>
{items.map((item, index) => (
<div key={`${keyPrefix}_${index}`}>
{item?.condition
? item.condition()
? _renderOverlayItem(item)
: null
: _renderOverlayItem(item)}
</div>
))}
</>
);
},
[_renderOverlayItem]
);
return (
<ViewportOverlay
topLeft={getTopLeftContent()}
topRight={getTopRightContent()}
bottomLeft={getBottomLeftContent()}
bottomRight={getBottomRightContent()}
topLeft={
/**
* Inline default overlay items for a more standard expansion
*/
getContent(
topLeftCustomization,
[
{
id: 'WindowLevel',
customizationType: 'ohif.overlayItem.windowLevel',
},
{
id: 'ZoomLevel',
customizationType: 'ohif.overlayItem.zoomLevel',
condition: () => {
const activeToolName = toolGroupService.getActiveToolForViewport(viewportId);
return activeToolName === 'Zoom';
},
},
],
'topLeftOverlayItem'
)
}
topRight={getContent(
topRightCustomization,
[
{
id: 'InstanceNumber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
],
'topRightOverlayItem'
)}
bottomLeft={getContent(bottomLeftCustomization, [null], 'bottomLeftOverlayItem')}
bottomRight={getContent(bottomRightCustomization, [null], 'bottomRightOverlayItem')}
/>
);
}
function _getViewportInstance(viewportData, imageIndex) {
const getViewportInstance = (viewportData, imageIndex) => {
const { viewportType, data } = viewportData;
let imageId = null;
if (viewportData.viewportType === Enums.ViewportType.STACK) {
imageId = viewportData.data.imageIds[imageIndex];
} else if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
const volumes = viewportData.data;
if (volumes && volumes.length == 1) {
const volume = volumes[0];
imageId = volume.imageIds[imageIndex];
}
}
return imageId ? metaData.get('instance', imageId) || {} : {};
}
function _getInstanceNumber(viewportData, viewportId, imageIndex, cornerstoneViewportService) {
switch (viewportType) {
case Enums.ViewportType.STACK:
imageId = data.imageIds[imageIndex];
break;
case Enums.ViewportType.ORTHOGRAPHIC:
if (data?.length === 1) {
imageId = data[0].imageIds[imageIndex];
}
break;
default:
break;
}
return imageId ? metaData.get('instance', imageId) || {} : {};
};
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
let instanceNumber;
if (viewportData.viewportType === Enums.ViewportType.STACK) {
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
if (!instanceNumber && instanceNumber !== 0) {
return null;
}
} else if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
instanceNumber = _getInstanceNumberFromVolume(
viewportData,
imageIndex,
viewportId,
cornerstoneViewportService
);
switch (viewportData.viewportType) {
case Enums.ViewportType.STACK:
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
break;
case Enums.ViewportType.ORTHOGRAPHIC:
instanceNumber = _getInstanceNumberFromVolume(
viewportData,
viewportId,
cornerstoneViewportService
);
break;
}
return instanceNumber;
}
return instanceNumber ?? null;
};
function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.data.imageIds;
@@ -442,6 +374,68 @@ function _getInstanceNumberFromVolume(viewportData, viewportId, cornerstoneViewp
}
}
/**
* Window Level / Center Overlay item
*/
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
const { windowWidth, windowCenter } = voi;
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
return null;
}
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">W:</span>
<span className="ml-1 mr-2 shrink-0 font-light">{windowWidth.toFixed(0)}</span>
<span className="mr-1 shrink-0">L:</span>
<span className="ml-1 shrink-0 font-light">{windowCenter.toFixed(0)}</span>
</div>
);
}
/**
* Zoom Level Overlay item
*/
function ZoomOverlayItem({ scale, customization }: OverlayItemProps) {
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">Zoom:</span>
<span className="font-light">{scale.toFixed(2)}x</span>
</div>
);
}
/**
* Instance Number Overlay Item
*/
function InstanceNumberOverlayItem({
instanceNumber,
imageSliceData,
customization,
}: OverlayItemProps) {
const { imageIndex, numberOfSlices } = imageSliceData;
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">I:</span>
<span className="font-light">
{instanceNumber !== undefined && instanceNumber !== null
? `${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`
: `${imageIndex + 1}/${numberOfSlices}`}
</span>
</div>
);
}
CustomizableViewportOverlay.propTypes = {
viewportData: PropTypes.object,
imageIndex: PropTypes.number,
@@ -1,278 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { vec3 } from 'gl-matrix';
import PropTypes from 'prop-types';
import { metaData, Enums, utilities } from '@cornerstonejs/core';
import { ViewportOverlay } from '@ohif/ui';
import { ServicesManager } from '@ohif/core';
const EPSILON = 1e-4;
function CornerstoneViewportOverlay({
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
}) {
const { cornerstoneViewportService, toolbarService } = servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const [activeTools, setActiveTools] = useState([]);
/**
* Initial toolbar state
*/
useEffect(() => {
setActiveTools(toolbarService.getActiveTools());
}, []);
useEffect(() => {
let isMounted = true;
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => {
if (!isMounted) {
return;
}
setActiveTools(toolbarService.getActiveTools());
}
);
return () => {
isMounted = false;
unsubscribe();
};
}, []);
/**
* Updating the VOI when the viewport changes its voi
*/
useEffect(() => {
const updateVOI = eventDetail => {
const { range } = eventDetail.detail;
if (!range) {
return;
}
const { lower, upper } = range;
const { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(lower, upper);
setVOI({ windowCenter, windowWidth });
};
element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
return () => {
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
};
}, [viewportId, viewportData, voi, element]);
/**
* Updating the scale when the viewport changes its zoom
*/
useEffect(() => {
const updateScale = eventDetail => {
const { previousCamera, camera } = eventDetail.detail;
if (
previousCamera.parallelScale !== camera.parallelScale ||
previousCamera.scale !== camera.scale
) {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
const imageData = viewport.getImageData();
if (!imageData) {
return;
}
if (camera.scale) {
setScale(camera.scale);
return;
}
const { spacing } = imageData;
// convert parallel scale to scale
const scale = (element.clientHeight * spacing[0] * 0.5) / camera.parallelScale;
setScale(scale);
}
};
element.addEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
return () => {
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
};
}, [viewportId, viewportData]);
const getTopLeftContent = useCallback(() => {
const { windowWidth, windowCenter } = voi;
if (activeTools.includes('WindowLevel')) {
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
return null;
}
return (
<div className="flex flex-row text-base">
<span className="mr-1">W:</span>
<span className="ml-1 mr-2 font-light">{windowWidth.toFixed(0)}</span>
<span className="mr-1">L:</span>
<span className="ml-1 font-light">{windowCenter.toFixed(0)}</span>
</div>
);
}
if (activeTools.includes('Zoom')) {
return (
<div className="flex flex-row text-base">
<span className="mr-1">Zoom:</span>
<span className="font-light">{scale.toFixed(2)}x</span>
</div>
);
}
return null;
}, [voi, scale, activeTools]);
const getTopRightContent = useCallback(() => {
const { imageIndex, numberOfSlices } = imageSliceData;
if (!viewportData) {
return;
}
let instanceNumber;
if (viewportData.viewportType === Enums.ViewportType.STACK) {
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
if (!instanceNumber) {
return null;
}
} else if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
instanceNumber = _getInstanceNumberFromVolume(
viewportData,
imageIndex,
viewportId,
cornerstoneViewportService
);
}
return (
<div className="flex flex-row text-base">
<span className="mr-1">I:</span>
<span className="font-light">
{instanceNumber !== undefined
? `${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`
: `${imageIndex + 1}/${numberOfSlices}`}
</span>
</div>
);
}, [imageSliceData, viewportData, viewportId]);
if (!viewportData) {
return null;
}
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
if (!ohifViewport) {
return null;
}
const backgroundColor = ohifViewport.getViewportOptions().background;
// Todo: probably this can be done in a better way in which we identify bright
// background
const isLight = backgroundColor ? utilities.isEqual(backgroundColor, [1, 1, 1]) : false;
return (
<ViewportOverlay
topLeft={getTopLeftContent()}
topRight={getTopRightContent()}
color={isLight && 'text-[#0944B3]'}
/>
);
}
function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.data.imageIds;
const imageId = imageIds[imageIndex];
if (!imageId) {
return;
}
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
const { instanceNumber } = generalImageModule;
const stackSize = imageIds.length;
if (stackSize <= 1) {
return;
}
return parseInt(instanceNumber);
}
// Since volume viewports can be in any view direction, they can render
// a reconstructed image which don't have imageIds; therefore, no instance and instanceNumber
// Here we check if viewport is in the acquisition direction and if so, we get the instanceNumber
function _getInstanceNumberFromVolume(
viewportData,
imageIndex,
viewportId,
cornerstoneViewportService
) {
const volumes = viewportData.volumes;
// Todo: support fusion of acquisition plane which has instanceNumber
if (!volumes || volumes.length > 1) {
return;
}
const volume = volumes[0];
const { direction, imageIds } = volume;
const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!cornerstoneViewport) {
return;
}
const camera = cornerstoneViewport.getCamera();
const { viewPlaneNormal } = camera;
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
const scanAxisNormal = direction.slice(6, 9);
// check if viewPlaneNormal is parallel to scanAxisNormal
const cross = vec3.cross(vec3.create(), viewPlaneNormal, scanAxisNormal);
const isAcquisitionPlane = vec3.length(cross) < EPSILON;
if (isAcquisitionPlane) {
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
}
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
return parseInt(instanceNumber);
}
}
CornerstoneViewportOverlay.propTypes = {
viewportData: PropTypes.object,
imageIndex: PropTypes.number,
viewportId: PropTypes.string,
servicesManager: PropTypes.instanceOf(ServicesManager),
};
export default CornerstoneViewportOverlay;
+109 -94
View File
@@ -21,6 +21,12 @@ import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync';
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import { CornerstoneServices } from './types';
import toggleVOISliceSync from './utils/toggleVOISliceSync';
const toggleSyncFunctions = {
imageSlice: toggleImageSliceSync,
voi: toggleVOISliceSync,
};
function commandsModule({
servicesManager,
@@ -30,11 +36,11 @@ function commandsModule({
viewportGridService,
toolGroupService,
cineService,
toolbarService,
uiDialogService,
cornerstoneViewportService,
uiNotificationService,
measurementService,
syncGroupService,
} = servicesManager.services as CornerstoneServices;
const { measurementServiceSource } = this;
@@ -227,30 +233,10 @@ function commandsModule({
arrowTextCallback: ({ callback, data }) => {
callInputDialog(uiDialogService, data, callback);
},
cleanUpCrosshairs: () => {
// if the crosshairs tool is active, deactivate it and set window level active
// since we are going back to main non-mpr HP
const activeViewportToolGroup = toolGroupService.getToolGroup(null);
if (activeViewportToolGroup._toolInstances?.Crosshairs?.mode === Enums.ToolModes.Active) {
actions.toolbarServiceRecordInteraction({
interactionType: 'tool',
commands: [
{
commandOptions: {
toolName: 'WindowLevel',
},
context: 'CORNERSTONE',
},
],
});
}
},
toggleCine: () => {
const { viewports } = viewportGridService.getState();
const { isCineEnabled } = cineService.getState();
cineService.setIsCineEnabled(!isCineEnabled);
toolbarService.setButton('Cine', { props: { isActive: !isCineEnabled } });
viewports.forEach((_, index) => cineService.setCine({ id: index, isPlaying: false }));
},
setWindowLevel({ window, level, toolGroupId }) {
@@ -279,35 +265,40 @@ function commandsModule({
});
viewport.render();
},
setToolEnabled: ({ toolName, toggle }) => {
const { viewports } = viewportGridService.getState();
// Just call the toolbar service record interaction - allows
// executing a toolbar command as a full toolbar command with side affects
// coming from the ToolbarService itself.
toolbarServiceRecordInteraction: props => {
toolbarService.recordInteraction(props);
},
// Enable or disable a toggleable command, without calling the activation
// Used to setup already active tools from hanging protocols
setToolbarToggled: props => {
toolbarService.setToggled(props.toolId, props.isActive ?? true);
},
setToolActive: ({ toolName, toolGroupId = null, toggledState }) => {
if (toolName === 'Crosshairs') {
const activeViewportToolGroup = toolGroupService.getToolGroup(null);
if (!activeViewportToolGroup._toolInstances.Crosshairs) {
uiNotificationService.show({
title: 'Crosshairs',
message:
'You need to be in a MPR view to use Crosshairs. Click on MPR button in the toolbar to activate it.',
type: 'info',
duration: 3000,
});
throw new Error('Crosshairs tool is not available in this viewport');
}
if (!viewports.size) {
return;
}
const toolGroup = toolGroupService.getToolGroup(null);
if (!toolGroup) {
return;
}
const toolIsEnabled = toolGroup.getToolOptions(toolName).mode === Enums.ToolModes.Enabled;
// Toggle the tool's state only if the toggle is true
if (toggle) {
toolIsEnabled ? toolGroup.setToolDisabled(toolName) : toolGroup.setToolEnabled(toolName);
}
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
setToolActiveToolbar: ({ value, itemId, toolGroupIds = [] }) => {
// Sometimes it is passed as value (tools with options), sometimes as itemId (toolbar buttons)
const toolName = itemId || value;
toolGroupIds = toolGroupIds.length ? toolGroupIds : toolGroupService.getToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
actions.setToolActive({ toolName, toolGroupId });
});
},
setToolActive: ({ toolName, toolGroupId = null }) => {
const { viewports } = viewportGridService.getState();
if (!viewports.size) {
@@ -320,34 +311,13 @@ function commandsModule({
return;
}
if (!toolGroup.getToolInstance(toolName)) {
uiNotificationService.show({
title: `${toolName} tool`,
message: `The ${toolName} tool is not available in this viewport.`,
type: 'info',
duration: 3000,
});
throw new Error(`ToolGroup ${toolGroup.id} does not have this tool.`);
}
const activeToolName = toolGroup.getActivePrimaryMouseButtonTool();
if (activeToolName) {
// Todo: this is a hack to prevent the crosshairs to stick around
// after another tool is selected. We should find a better way to do this
if (activeToolName === 'Crosshairs') {
toolGroup.setToolDisabled(activeToolName);
} else {
toolGroup.setToolPassive(activeToolName);
}
}
// If there is a toggle state, then simply set the enabled/disabled state without
// setting the tool active.
if (toggledState != null) {
toggledState ? toolGroup.setToolEnabled(toolName) : toolGroup.setToolDisabled(toolName);
return;
const activeToolOptions = toolGroup.getToolConfiguration(activeToolName);
activeToolOptions?.disableOnPassive
? toolGroup.setToolDisabled(activeToolName)
: toolGroup.setToolPassive(activeToolName);
}
// Set the new toolName to be active
@@ -565,33 +535,59 @@ function commandsModule({
(currentIndex + direction + viewportIds.length) % viewportIds.length;
viewportGridService.setActiveViewportId(viewportIds[nextViewportIndex] as string);
},
/**
* If the syncId is given and a synchronizer with that ID already exists, it will
* toggle it on/off for the provided viewports. If not, it will attempt to create
* a new synchronizer using the given syncId and type for the specified viewports.
* If no viewports are provided, you may notice some default behavior.
* - 'voi' type, we will aim to synchronize all viewports with the same modality
* -'imageSlice' type, we will aim to synchronize all viewports with the same orientation.
*
* @param options
* @param options.viewports - The viewports to synchronize
* @param options.syncId - The synchronization group ID
* @param options.type - The type of synchronization to perform
*/
toggleSynchronizer: ({ type, viewports, syncId }) => {
const synchronizer = syncGroupService.getSynchronizer(syncId);
toggleImageSliceSync: ({ toggledState }) => {
toggleImageSliceSync({
servicesManager,
toggledState,
});
if (synchronizer) {
synchronizer.isDisabled() ? synchronizer.setEnabled(true) : synchronizer.setEnabled(false);
return;
}
const fn = toggleSyncFunctions[type];
if (fn) {
fn({
servicesManager,
viewports,
syncId,
});
}
},
setSourceViewportForReferenceLinesTool: ({ toggledState, viewportId }) => {
setSourceViewportForReferenceLinesTool: ({ viewportId }) => {
if (!viewportId) {
const { activeViewportId } = viewportGridService.getState();
viewportId = activeViewportId;
viewportId = activeViewportId ?? 'default';
}
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
toolGroup.setToolConfiguration(
toolGroup?.setToolConfiguration(
ReferenceLinesTool.toolName,
{
sourceViewportId: viewportId,
},
true // overwrite
);
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
storePresentation: ({ viewportId }) => {
cornerstoneViewportService.storePresentation({ viewportId });
},
attachProtocolViewportDataListener: ({ protocol, stageIndex }) => {
const EVENT = cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED;
const command = protocol.callbacks.onViewportDataInitialized;
@@ -608,6 +604,26 @@ function commandsModule({
}
});
},
resetCrosshairs: ({ viewportId }) => {
const crosshairInstances = [];
const getCrosshairInstances = toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
crosshairInstances.push(toolGroup.getToolInstance('Crosshairs'));
};
if (!viewportId) {
const toolGroupIds = toolGroupService.getToolGroupIds();
toolGroupIds.forEach(getCrosshairInstances);
} else {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
getCrosshairInstances(toolGroup.id);
}
crosshairInstances.forEach(ins => {
ins.resetCrosshairs();
});
},
};
const definitions = {
@@ -615,7 +631,6 @@ function commandsModule({
// context menu
showCornerstoneContextMenu: {
commandFn: actions.showCornerstoneContextMenu,
storeContexts: [],
options: {
menuCustomizationId: 'measurementsContextMenu',
commands: [
@@ -631,8 +646,6 @@ function commandsModule({
},
getNearbyAnnotation: {
commandFn: actions.getNearbyAnnotation,
storeContexts: [],
options: {},
},
deleteMeasurement: {
commandFn: actions.deleteMeasurement,
@@ -643,16 +656,18 @@ function commandsModule({
updateMeasurement: {
commandFn: actions.updateMeasurement,
},
setWindowLevel: {
commandFn: actions.setWindowLevel,
},
toolbarServiceRecordInteraction: {
commandFn: actions.toolbarServiceRecordInteraction,
},
setToolActive: {
commandFn: actions.setToolActive,
},
setToolActiveToolbar: {
commandFn: actions.setToolActiveToolbar,
},
setToolEnabled: {
commandFn: actions.setToolEnabled,
},
rotateViewportCW: {
commandFn: actions.rotateViewport,
options: { rotation: 90 },
@@ -735,15 +750,15 @@ function commandsModule({
storePresentation: {
commandFn: actions.storePresentation,
},
setToolbarToggled: {
commandFn: actions.setToolbarToggled,
},
cleanUpCrosshairs: {
commandFn: actions.cleanUpCrosshairs,
},
attachProtocolViewportDataListener: {
commandFn: actions.attachProtocolViewportDataListener,
},
resetCrosshairs: {
commandFn: actions.resetCrosshairs,
},
toggleSynchronizer: {
commandFn: actions.toggleSynchronizer,
},
};
return {
@@ -1,38 +1,18 @@
import React, { useCallback, useEffect, useState } from 'react';
import { CinePlayer, useCine, useViewportGrid } from '@ohif/ui';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { CinePlayer, useCine } from '@ohif/ui';
import { Enums, eventTarget } from '@cornerstonejs/core';
import { useAppConfig } from '@state';
function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
const {
toolbarService,
customizationService,
displaySetService,
viewportGridService,
cineService,
} = servicesManager.services;
const [{ isCineEnabled, cines }] = useCine();
const { customizationService, displaySetService, viewportGridService } = servicesManager.services;
const [{ isCineEnabled, cines }, api] = useCine();
const [newStackFrameRate, setNewStackFrameRate] = useState(24);
const [appConfig] = useAppConfig();
const isMountedRef = useRef(null);
const { component: CinePlayerComponent = CinePlayer } =
customizationService.get('cinePlayer') ?? {};
const handleCineClose = () => {
toolbarService.recordInteraction({
groupId: 'MoreTools',
interactionType: 'toggle',
commands: [
{
commandName: 'toggleCine',
commandOptions: {},
toolName: 'cine',
context: 'CORNERSTONE',
},
],
});
};
const cineHandler = () => {
if (!cines || !cines[viewportId] || !enabledVPElement) {
return;
@@ -45,11 +25,11 @@ function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
const validFrameRate = Math.max(frameRate, 1);
if (isPlaying) {
cineService.playClip(enabledVPElement, {
api.playClip(enabledVPElement, {
framesPerSecond: validFrameRate,
});
} else {
cineService.stopClip(enabledVPElement);
api.stopClip(enabledVPElement);
}
};
@@ -70,34 +50,36 @@ function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
});
if (isPlaying) {
cineService.setIsCineEnabled(isPlaying);
api.setIsCineEnabled(isPlaying);
}
cineService.setCine({ id: viewportId, isPlaying, frameRate });
api.setCine({ id: viewportId, isPlaying, frameRate });
setNewStackFrameRate(frameRate);
}, [cineService, displaySetService, viewportId, viewportGridService, cines]);
}, [displaySetService, viewportId, viewportGridService, cines]);
useEffect(() => {
isMountedRef.current = true;
eventTarget.addEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newStackCineHandler);
return () => {
cineService.setCine({ id: viewportId, isPlaying: false });
isMountedRef.current = false;
api.stopClip(enabledVPElement);
api.setCine({ id: viewportId, isPlaying: false });
eventTarget.removeEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newStackCineHandler);
};
}, [enabledVPElement, newStackCineHandler]);
useEffect(() => {
if (!cines || !cines[viewportId] || !enabledVPElement) {
if (!cines || !cines[viewportId] || !enabledVPElement || !isMountedRef.current) {
return;
}
cineHandler();
return () => {
if (enabledVPElement && cines?.[viewportId]?.isPlaying) {
cineService.stopClip(enabledVPElement);
}
api.stopClip(enabledVPElement);
};
}, [cines, viewportId, cineService, enabledVPElement, cineHandler]);
}, [cines, viewportId, enabledVPElement, cineHandler]);
const cine = cines[viewportId];
const isPlaying = (cine && cine.isPlaying) || false;
@@ -108,15 +90,22 @@ function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
className="absolute left-1/2 bottom-3 -translate-x-1/2"
frameRate={newStackFrameRate}
isPlaying={isPlaying}
onClose={handleCineClose}
onPlayPauseChange={isPlaying =>
cineService.setCine({
onClose={() => {
// also stop the clip
api.setCine({
id: viewportId,
isPlaying: false,
});
api.setIsCineEnabled(false);
}}
onPlayPauseChange={isPlaying => {
api.setCine({
id: viewportId,
isPlaying,
})
}
});
}}
onFrameRateChange={frameRate =>
cineService.setCine({
api.setCine({
id: viewportId,
frameRate,
})
@@ -0,0 +1,251 @@
import { Enums } from '@cornerstonejs/tools';
const getToggledClassName = (isToggled: boolean) => {
return isToggled
? '!text-primary-active'
: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light';
};
export default function getToolbarModule({ commandsManager, servicesManager }) {
const {
toolGroupService,
syncGroupService,
cornerstoneViewportService,
hangingProtocolService,
displaySetService,
viewportGridService,
} = servicesManager.services;
return [
// functions/helpers to be used by the toolbar buttons to decide if they should
// enabled or not
{
name: 'evaluate.cornerstoneTool',
evaluate: ({ viewportId, button }) => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
const toolName = getToolNameForButton(button);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
};
}
const isPrimaryActive = toolGroup.getActivePrimaryMouseButtonTool() === toolName;
return {
disabled: false,
className: isPrimaryActive
? '!text-black bg-primary-light'
: '!text-common-bright hover:!bg-primary-dark hover:!text-primary-light',
// Todo: isActive right now is used for nested buttons where the primary
// button needs to be fully rounded (vs partial rounded) when active
// otherwise it does not have any other use
isActive: isPrimaryActive,
};
},
},
{
name: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList',
evaluate: ({ viewportId, button, itemId }) => {
const { items } = button.props;
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return {
primary: button.props.primary,
items,
};
}
const activeToolName = toolGroup.getActivePrimaryMouseButtonTool();
// check if the active toolName is part of the items then we need
// to move it to the primary button
const activeToolIndex = items.findIndex(item => {
const toolName = getToolNameForButton(item);
return toolName === activeToolName;
});
// if there is an active tool in the items dropdown bound to the primary mouse/touch
// we should show that no matter what
if (activeToolIndex > -1) {
return {
primary: items[activeToolIndex],
items,
};
}
if (!itemId) {
return {
primary: button.props.primary,
items,
};
}
// other wise we can move the clicked tool to the primary button
const clickedItemProps = items.find(item => item.id === itemId || item.itemId === itemId);
return {
primary: clickedItemProps,
items,
};
},
},
{
name: 'evaluate.action',
evaluate: ({ viewportId, button }) => {
return {
className: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
},
},
{
name: 'evaluate.cornerstoneTool.toggle',
evaluate: ({ viewportId, button }) => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
const toolName = getToolNameForButton(button);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
};
}
const isOff = [Enums.ToolModes.Disabled, Enums.ToolModes.Passive].includes(
toolGroup.getToolOptions(toolName).mode
);
return {
className: getToggledClassName(!isOff),
};
},
},
{
name: 'evaluate.cornerstone.synchronizer',
evaluate: ({ viewportId, button }) => {
let synchronizers = syncGroupService.getSynchronizersForViewport(viewportId);
if (!synchronizers?.length) {
return {
className: getToggledClassName(false),
};
}
const synchronizerType = button?.commands?.[0]?.commandOptions?.type;
synchronizers = syncGroupService.getSynchronizersOfType(synchronizerType);
if (!synchronizers?.length) {
return {
className: getToggledClassName(false),
};
}
// Todo: we need a better way to find the synchronizers based on their
// type, but for now we just check the first one and see if it is
// enabled
const synchronizer = synchronizers[0];
const isEnabled = synchronizer?._enabled;
return {
className: getToggledClassName(isEnabled),
};
},
},
{
name: 'evaluate.viewportProperties.toggle',
evaluate: ({ viewportId, button }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport.isDisabled) {
return;
}
const propId = button.id;
const properties = viewport.getProperties();
const camera = viewport.getCamera();
const prop = camera?.[propId] || properties?.[propId];
if (!prop) {
return {
disabled: false,
className: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
}
const isToggled = prop;
return {
className: getToggledClassName(isToggled),
};
},
},
{
name: 'evaluate.mpr',
evaluate: ({ viewportId, button }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetUIDs?.length) {
return;
}
const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID);
const areReconstructable = displaySets.every(displaySet => {
return displaySet.isReconstructable;
});
if (!areReconstructable) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
};
}
const isMpr = protocol?.id === 'mpr';
return {
disabled: false,
className: getToggledClassName(isMpr),
};
},
},
];
}
function getToolNameForButton(button) {
const { props } = button;
const commands = props?.commands || button.commands;
const commandsArray = Array.isArray(commands) ? commands : [commands];
const firstCommand = commandsArray[0];
if (typeof firstCommand === 'string') {
// likely not a cornerstone tool
return null;
}
if ('commandOptions' in firstCommand) {
return firstCommand.commandOptions.toolName ?? props?.id ?? button.id;
}
// use id as a fallback for toolName
return props?.id ?? button.id;
}
-5
View File
@@ -22,11 +22,6 @@ export const mpr: Types.HangingProtocol.Protocol = {
},
],
// Turns off crosshairs when switching out of MPR mode
onProtocolExit: [
{
commandName: 'cleanUpCrosshairs',
},
],
},
displaySetSelectors: {
activeDisplaySet: {
+2 -1
View File
@@ -13,6 +13,7 @@ import init from './init';
import getCustomizationModule from './getCustomizationModule';
import getCommandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getToolbarModule from './getToolbarModule';
import ToolGroupService from './services/ToolGroupService';
import SyncGroupService from './services/SyncGroupService';
import SegmentationService from './services/SegmentationService';
@@ -26,7 +27,6 @@ import dicomLoaderService from './utils/dicomLoaderService';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import { id } from './id';
import * as csWADOImageLoader from './initWADOImageLoader.js';
import { measurementMappingUtils } from './utils/measurementServiceMappings';
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
@@ -79,6 +79,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
return init.call(this, props);
},
getToolbarModule,
getHangingProtocolModule,
getViewportModule({ servicesManager, commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => {
+27 -77
View File
@@ -15,7 +15,6 @@ import {
utilities as csUtilities,
Enums as csEnums,
} from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools';
import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader';
import initWADOImageLoader from './initWADOImageLoader';
@@ -41,7 +40,6 @@ export default async function init({
servicesManager,
commandsManager,
extensionManager,
configuration,
appConfig,
}: Types.Extensions.ExtensionParams): Promise<void> {
// Note: this should run first before initializing the cornerstone
@@ -94,13 +92,26 @@ export default async function init({
cineService,
cornerstoneViewportService,
hangingProtocolService,
toolGroupService,
toolbarService,
viewportGridService,
stateSyncService,
syncGroupService,
segmentationService,
} = servicesManager.services as CornerstoneServices;
toolbarService.registerEventForToolbarUpdate(cornerstoneViewportService, [
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
]);
toolbarService.registerEventForToolbarUpdate(segmentationService, [
segmentationService.EVENTS.SEGMENTATION_ADDED,
segmentationService.EVENTS.SEGMENTATION_REMOVED,
segmentationService.EVENTS.SEGMENTATION_UPDATED,
]);
toolbarService.registerEventForToolbarUpdate(eventTarget, [
cornerstoneTools.Enums.Events.TOOL_ACTIVATED,
]);
window.services = servicesManager.services;
window.extensionManager = extensionManager;
window.commandsManager = commandsManager;
@@ -225,47 +236,6 @@ export default async function init({
commandsManager,
});
/**
* When a viewport gets a new display set, this call will go through all the
* active tools in the toolbar, and call any commands registered in the
* toolbar service with a callback to re-enable on displaying the viewport.
*/
const toolbarEventListener = evt => {
const { element } = evt.detail;
const activeTools = toolbarService.getActiveTools();
activeTools.forEach(tool => {
const toolData = toolbarService.getNestedButton(tool);
const commands = toolData?.listeners?.[evt.type];
commandsManager.run(commands, { element, evt });
});
};
/** Listens for active viewport events and fires the toolbar listeners */
const activeViewportEventListener = evt => {
const { viewportId } = evt;
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
const activeTools = toolbarService.getActiveTools();
activeTools.forEach(tool => {
if (!toolGroup?._toolInstances?.[tool]) {
return;
}
// check if tool is active on the new viewport
const toolEnabled = toolGroup._toolInstances[tool].mode === Enums.ToolModes.Enabled;
if (!toolEnabled) {
return;
}
const button = toolbarService.getNestedButton(tool);
const commands = button?.listeners?.[evt.type];
commandsManager.run(commands, { viewportId, evt });
});
};
/**
* Runs error handler for failed requests.
* @param event
@@ -275,30 +245,6 @@ export default async function init({
handler(detail.error);
};
const resetCrosshairs = evt => {
const { element } = evt.detail;
const { viewportId, renderingEngineId } = cornerstone.getEnabledElement(element);
const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroupForViewport(
viewportId,
renderingEngineId
);
if (!toolGroup || !toolGroup._toolInstances?.['Crosshairs']) {
return;
}
const mode = toolGroup._toolInstances['Crosshairs'].mode;
if (mode === Enums.ToolModes.Active) {
toolGroup.setToolActive('Crosshairs');
} else if (mode === Enums.ToolModes.Passive) {
toolGroup.setToolPassive('Crosshairs');
} else if (mode === Enums.ToolModes.Enabled) {
toolGroup.setToolEnabled('Crosshairs');
}
};
eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, evt => {
const { element } = evt.detail;
cornerstoneTools.utilities.stackContextPrefetch.enable(element);
@@ -309,17 +255,21 @@ export default async function init({
function elementEnabledHandler(evt) {
const { element } = evt.detail;
element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
element.addEventListener(EVENTS.CAMERA_RESET, evt => {
const { element } = evt.detail;
const { viewportId } = getEnabledElement(element);
commandsManager.runCommand('resetCrosshairs', { viewportId });
});
eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, toolbarEventListener);
// eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, toolbarEventListener);
initViewTiming({ element, eventTarget });
initViewTiming({ element });
}
function elementDisabledHandler(evt) {
const { element } = evt.detail;
element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
// element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
// TODO - consider removing the callback when all elements are gone
// eventTarget.removeEventListener(
@@ -332,10 +282,10 @@ export default async function init({
eventTarget.addEventListener(EVENTS.ELEMENT_DISABLED, elementDisabledHandler.bind(null));
viewportGridService.subscribe(
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
activeViewportEventListener
);
// viewportGridService.subscribe(
// viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
// activeViewportEventListener
// );
}
function CPUModal() {
@@ -962,7 +962,7 @@ class SegmentationService extends PubSubService {
// Force use of a Uint8Array SharedArrayBuffer for the segmentation to save space and so
// it is easily compressible in worker thread.
await volumeLoader.createAndCacheDerivedVolume(volumeId, {
await volumeLoader.createAndCacheDerivedSegmentationVolume(volumeId, {
volumeId: segmentationId,
targetBuffer: {
type: 'Uint8Array',
@@ -1368,8 +1368,6 @@ class SegmentationService extends PubSubService {
public setConfiguration = (configuration: SegmentationConfig): void => {
const {
brushSize,
brushThresholdGate,
fillAlpha,
fillAlphaInactive,
outlineWidthActive,
@@ -1461,7 +1459,6 @@ class SegmentationService extends PubSubService {
segmentInfo.label = label;
if (suppressEvents === false) {
// this._setSegmentationModified(segmentationId);
this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, {
segmentation,
});
@@ -1,4 +1,5 @@
import { synchronizers, SynchronizerManager, Synchronizer } from '@cornerstonejs/tools';
import { getRenderingEngines } from '@cornerstonejs/core';
import { pubSubServiceInterface, Types, ServicesManager } from '@ohif/core';
@@ -25,6 +26,7 @@ const POSITION = 'cameraposition';
const VOI = 'voi';
const ZOOMPAN = 'zoompan';
const STACKIMAGE = 'stackimage';
const IMAGE_SLICE = 'imageslice';
const asSyncGroup = (syncGroup: string | SyncGroup): SyncGroup =>
typeof syncGroup === 'string' ? { type: syncGroup } : syncGroup;
@@ -45,9 +47,14 @@ export default class SyncGroupService {
[POSITION]: synchronizers.createCameraPositionSynchronizer,
[VOI]: synchronizers.createVOISynchronizer,
[ZOOMPAN]: synchronizers.createZoomPanSynchronizer,
// todo: remove stack image since it is legacy now and the image_slice
// handles both stack and volume viewports
[STACKIMAGE]: synchronizers.createImageSliceSynchronizer,
[IMAGE_SLICE]: synchronizers.createImageSliceSynchronizer,
};
synchronizersByType: { [key: string]: Synchronizer[] } = {};
constructor(serviceManager: ServicesManager) {
this.servicesManager = serviceManager;
this.listeners = {};
@@ -57,14 +64,27 @@ export default class SyncGroupService {
}
private _createSynchronizer(type: string, id: string, options): Synchronizer | undefined {
// Initialize if not already done
this.synchronizersByType[type] = this.synchronizersByType[type] || [];
const syncCreator = this.synchronizerCreators[type.toLowerCase()];
if (syncCreator) {
return syncCreator(id, options);
const synchronizer = syncCreator(id, options);
if (synchronizer) {
this.synchronizersByType[type].push(synchronizer);
return synchronizer;
}
} else {
console.warn('Unknown synchronizer type', type, id);
console.warn(`Unknown synchronizer type: ${type}, id: ${id}`);
}
}
public getSyncCreatorForType(type: string): SyncCreator {
return this.synchronizerCreators[type.toLowerCase()];
}
/**
* Creates a synchronizer type.
* @param type is the type of the synchronizer to create
@@ -78,6 +98,15 @@ export default class SyncGroupService {
return SynchronizerManager.getSynchronizer(id);
}
/**
* Retrieves an array of synchronizers of a specific type.
* @param type - The type of synchronizers to retrieve.
* @returns An array of synchronizers of the specified type.
*/
public getSynchronizersOfType(type: string): Synchronizer[] {
return this.synchronizersByType[type];
}
protected _getOrCreateSynchronizer(
type: string,
id: string,
@@ -125,14 +154,17 @@ export default class SyncGroupService {
SynchronizerManager.destroy();
}
public getSynchronizersForViewport(
viewportId: string,
renderingEngineId: string
): Synchronizer[] {
return SynchronizerManager.getAllSynchronizers().filter(
public getSynchronizersForViewport(viewportId: string): Synchronizer[] {
const renderingEngine =
getRenderingEngines().find(re => {
return re.getViewports().find(vp => vp.id === viewportId);
}) || getRenderingEngines()[0];
const synchronizers = SynchronizerManager.getAllSynchronizers();
return synchronizers.filter(
s =>
s.hasSourceViewport(renderingEngineId, viewportId) ||
s.hasTargetViewport(renderingEngineId, viewportId)
s.hasSourceViewport(renderingEngine.id, viewportId) ||
s.hasTargetViewport(renderingEngine.id, viewportId)
);
}
@@ -189,19 +189,6 @@ export default class ToolGroupService {
this.addToolsToToolGroup(toolGroupId, tools);
return toolGroup;
}
/**
private changeConfigurationIfNecessary(toolGroup, volumeUID) {
// handle specific assignment for volumeUID (e.g., fusion)
const toolInstances = toolGroup._toolInstances;
// Object.values(toolInstances).forEach(toolInstance => {
// if (toolInstance.configuration) {
// toolInstance.configuration.volumeUID = volumeUID;
// }
// });
}
*/
/**
* Get the tool's configuration based on the tool name and tool group id
* @param toolGroupId - The id of the tool group that the tool instance belongs to.
@@ -234,7 +234,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
}
const { viewPlaneNormal, viewUp } = csViewport.getCamera();
const initialImageIndex = csViewport.getCurrentImageIdIndex();
const initialImageIndex = csViewport.getCurrentImageIdIndex() || 0;
const zoom = csViewport.getZoom();
const pan = csViewport.getPan();
@@ -347,10 +347,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
const { stateSyncService, syncGroupService } = this.servicesManager.services;
const synchronizers = syncGroupService.getSynchronizersForViewport(
viewportId,
this.renderingEngine.id
);
const synchronizers = syncGroupService.getSynchronizersForViewport(viewportId);
const { positionPresentationStore, synchronizersStore, lutPresentationStore } =
stateSyncService.getState();
@@ -189,7 +189,7 @@ const CornerstoneViewportDownloadForm = ({
// add the viewport to the toolGroup
toolGroup.addViewport(downloadViewportId, renderingEngineId);
Object.keys(toolGroup._toolInstances).forEach(toolName => {
Object.keys(toolGroup.getToolInstances()).forEach(toolName => {
// make all tools Enabled so that they can not be interacted with
// in the download viewport
if (toggle && toolName !== 'Crosshairs') {
@@ -1,20 +1,35 @@
const IMAGE_SLICE_SYNC_NAME = 'IMAGE_SLICE_SYNC';
export default function toggleImageSliceSync({
toggledState,
servicesManager,
viewports: providedViewports,
syncId,
}) {
if (!toggledState) {
return disableSync(IMAGE_SLICE_SYNC_NAME, servicesManager);
}
const { syncGroupService, viewportGridService, displaySetService, cornerstoneViewportService } =
servicesManager.services;
syncId ||= IMAGE_SLICE_SYNC_NAME;
const viewports =
providedViewports || getReconstructableStackViewports(viewportGridService, displaySetService);
// Todo: right now we don't have a proper way to define specific
// viewports to add to synchronizers, and right now it is global or not
// after we do that, we should do fine grained control of the synchronizers
const someViewportHasSync = viewports.some(viewport => {
const syncStates = syncGroupService.getSynchronizersForViewport(
viewport.viewportOptions.viewportId
);
const imageSync = syncStates.find(syncState => syncState.id === syncId);
return !!imageSync;
});
if (someViewportHasSync) {
return disableSync(syncId, servicesManager);
}
// create synchronization group and add the viewports to it.
viewports.forEach(gridViewport => {
const { viewportId } = gridViewport.viewportOptions;
@@ -23,8 +38,8 @@ export default function toggleImageSliceSync({
return;
}
syncGroupService.addViewportToSyncGroup(viewportId, viewport.getRenderingEngine().id, {
type: 'stackimage',
id: IMAGE_SLICE_SYNC_NAME,
type: 'imageSlice',
id: syncId,
source: true,
target: true,
});
@@ -0,0 +1,94 @@
const VOI_SYNC_NAME = 'VOI_SYNC';
const getSyncId = modality => `${VOI_SYNC_NAME}_${modality}`;
export default function toggleVOISliceSync({
servicesManager,
viewports: providedViewports,
syncId,
}) {
const { syncGroupService, viewportGridService, displaySetService, cornerstoneViewportService } =
servicesManager.services;
const viewports =
providedViewports || groupViewportsByModality(viewportGridService, displaySetService);
// Todo: right now we don't have a proper way to define specific
// viewports to add to synchronizers, and right now it is global or not
// after we do that, we should do fine grained control of the synchronizers
// we can apply voi sync within each modality group
for (const [modality, modalityViewports] of Object.entries(viewports)) {
const syncIdToUse = syncId || getSyncId(modality);
const someViewportHasSync = modalityViewports.some(viewport => {
const syncStates = syncGroupService.getSynchronizersForViewport(
viewport.viewportOptions.viewportId
);
const imageSync = syncStates.find(syncState => syncState.id === syncIdToUse);
return !!imageSync;
});
if (someViewportHasSync) {
return disableSync(modalityViewports, syncIdToUse, servicesManager);
}
// create synchronization group and add the modalityViewports to it.
modalityViewports.forEach(gridViewport => {
const { viewportId } = gridViewport.viewportOptions;
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
syncGroupService.addViewportToSyncGroup(viewportId, viewport.getRenderingEngine().id, {
type: 'voi',
id: syncIdToUse,
source: true,
target: true,
});
});
}
}
function disableSync(modalityViewports, syncId, servicesManager) {
const { syncGroupService, cornerstoneViewportService } = servicesManager.services;
const viewports = modalityViewports;
viewports.forEach(gridViewport => {
const { viewportId } = gridViewport.viewportOptions;
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
syncGroupService.removeViewportFromSyncGroup(
viewport.id,
viewport.getRenderingEngine().id,
syncId
);
});
}
function groupViewportsByModality(viewportGridService, displaySetService) {
let { viewports } = viewportGridService.getState();
viewports = [...viewports.values()];
// group the viewports by modality
return viewports.reduce((acc, viewport) => {
const { displaySetInstanceUIDs } = viewport;
// Todo: add proper fusion support
const displaySetInstanceUID = displaySetInstanceUIDs[0];
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
const modality = displaySet.Modality;
if (!acc[modality]) {
acc[modality] = [];
}
acc[modality].push(viewport);
return acc;
}, {});
}
@@ -60,7 +60,6 @@ export default class ContextMenuController {
return;
}
console.log('Getting items from', menus);
const items = ContextMenuItemsBuilder.getMenuItems(
selectorProps || contextMenuProps,
event,
@@ -70,8 +70,6 @@ export function findMenu(menus: Menu[], props?: Types.IProps, menuIdFilter?: str
current = findIt.next();
}
console.log('Menu chosen', menu?.id || 'NONE');
return menu;
}
@@ -5,6 +5,11 @@
function getImageSrcFromImageId(cornerstone, imageId) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
// Note: the default width and height of the canvas is 300x150
// but we need to set the width and height to the same number since
// the thumbnails are usually square and we want to maintain the aspect ratio
canvas.width = 128 / window.devicePixelRatio;
canvas.height = 128 / window.devicePixelRatio;
cornerstone.utilities
.loadImageToCanvas({ canvas, imageId })
.then(imageId => {
+39 -43
View File
@@ -1,57 +1,53 @@
import React, { useCallback, useEffect, useState } from 'react';
import React from 'react';
import { Tooltip } from '@ohif/ui';
import classnames from 'classnames';
import { useViewportGrid } from '@ohif/ui';
import { useToolbar } from '@ohif/core';
export default function Toolbar({
servicesManager,
}: Types.Extensions.ExtensionParams): React.ReactElement {
const { toolbarService } = servicesManager.services;
export function Toolbar({ servicesManager }) {
const { toolbarButtons, onInteraction } = useToolbar({
servicesManager,
buttonSection: 'primary',
});
const [viewportGrid, viewportGridService] = useViewportGrid();
const [toolbarButtons, setToolbarButtons] = useState([]);
useEffect(() => {
const updateToolbar = () => {
const toolGroupId =
viewportGridService.getActiveViewportOptionByKey('toolGroupId') ?? 'default';
setToolbarButtons(toolbarService.getButtonSection(toolGroupId));
};
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_MODIFIED,
updateToolbar
);
updateToolbar();
return () => {
unsubscribe();
};
}, [toolbarService, viewportGrid]);
const onInteraction = useCallback(
args => toolbarService.recordInteraction(args),
[toolbarService]
);
if (!toolbarButtons.length) {
return null;
}
return (
<>
{toolbarButtons.map(toolDef => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
return (
// The margin for separating the tools on the toolbar should go here and NOT in each individual component (button) item.
// This allows for the individual items to be included in other UI components where perhaps alternative margins are desired.
const { disabled } = componentProps;
const tool = (
<Component
key={id}
id={id}
onInteraction={onInteraction}
servicesManager={servicesManager}
{...componentProps}
/>
);
return disabled ? (
<Tooltip
key={id}
position="bottom"
content={componentProps.label}
secondaryContent={'Not available on the current viewport'}
>
<div className={classnames('mr-1')}>{tool}</div>
</Tooltip>
) : (
<div
key={id}
className={classnames('mr-1')}
className="mr-1"
>
<Component
id={id}
{...componentProps}
onInteraction={onInteraction}
servicesManager={servicesManager}
/>
{tool}
</div>
);
})}
@@ -0,0 +1,35 @@
import { ToolbarButton, ButtonGroup } from '@ohif/ui';
import React, { useCallback } from 'react';
function ToolbarButtonGroupWithServices({ groupId, items, onInteraction, size }) {
const getSplitButtonItems = useCallback(
items =>
items.map((item, index) => (
<ToolbarButton
key={item.id}
icon={item.icon}
label={item.label}
disabled={item.disabled}
className={item.className}
id={item.id}
size={size}
onClick={() => {
onInteraction({
groupId,
itemId: item.id,
commands: item.commands,
});
}}
// Note: this is necessary since tooltip will add
// default styles to the tooltip container which
// we don't want for groups
toolTipClassName=""
/>
)),
[onInteraction, groupId]
);
return <ButtonGroup>{getSplitButtonItems(items)}</ButtonGroup>;
}
export default ToolbarButtonGroupWithServices;
@@ -1,75 +0,0 @@
import { ToolbarButton } from '@ohif/ui';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
function ToolbarButtonWithServices({
id,
type,
commands,
onInteraction,
servicesManager,
...props
}) {
const { toolbarService } = servicesManager?.services || {};
const [buttonsState, setButtonState] = useState({
primaryToolId: '',
toggles: {},
groups: {},
});
const { primaryToolId } = buttonsState;
const isActive =
(type === 'tool' && id === primaryToolId) ||
(type === 'toggle' && buttonsState.toggles[id] === true);
useEffect(() => {
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
state => {
setButtonState({ ...state });
}
);
return () => {
unsubscribe();
};
}, [toolbarService]);
return (
<ToolbarButton
commands={commands}
id={id}
type={type}
isActive={isActive}
onInteraction={onInteraction}
{...props}
/>
);
}
ToolbarButtonWithServices.propTypes = {
id: PropTypes.string.isRequired,
type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired,
commands: PropTypes.arrayOf(
PropTypes.shape({
commandName: PropTypes.string.isRequired,
context: PropTypes.string,
})
),
onInteraction: PropTypes.func.isRequired,
servicesManager: PropTypes.shape({
services: PropTypes.shape({
toolbarService: PropTypes.shape({
subscribe: PropTypes.func.isRequired,
state: PropTypes.shape({
primaryToolId: PropTypes.string,
toggles: PropTypes.objectOf(PropTypes.bool),
groups: PropTypes.objectOf(PropTypes.any),
}).isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
export default ToolbarButtonWithServices;
@@ -3,21 +3,20 @@ import PropTypes from 'prop-types';
import { LayoutSelector as OHIFLayoutSelector, ToolbarButton } from '@ohif/ui';
import { ServicesManager } from '@ohif/core';
function ToolbarLayoutSelectorWithServices({ servicesManager, ...props }) {
const { toolbarService } = servicesManager.services;
function ToolbarLayoutSelectorWithServices({ servicesManager, commands, ...props }) {
const { toolbarService, viewportGridService } = servicesManager.services;
const onSelection = useCallback(
props => {
toolbarService.recordInteraction({
interactionType: 'action',
commands: [
{
commandName: 'setViewportGridLayout',
commandOptions: { ...props },
context: 'DEFAULT',
},
],
});
const onInteraction = useCallback(
args => {
const viewportId = viewportGridService.getActiveViewportId();
const refreshProps = {
viewportId,
};
if (props.onLayoutChange) {
props.onLayoutChange(args);
}
toolbarService.recordInteraction({ commands }, { ...args, refreshProps });
},
[toolbarService]
);
@@ -25,12 +24,12 @@ function ToolbarLayoutSelectorWithServices({ servicesManager, ...props }) {
return (
<LayoutSelector
{...props}
onSelection={onSelection}
onInteraction={onInteraction}
/>
);
}
function LayoutSelector({ rows, columns, className, onSelection, ...rest }) {
function LayoutSelector({ rows, columns, className, onInteraction, ...rest }) {
const [isOpen, setIsOpen] = useState(false);
const closeOnOutsideClick = () => {
@@ -62,7 +61,7 @@ function LayoutSelector({ rows, columns, className, onSelection, ...rest }) {
<DropdownContent
rows={rows}
columns={columns}
onSelection={onSelection}
onSelection={onInteraction}
/>
)
}
@@ -1,11 +1,8 @@
import { SplitButton, Icon, ToolbarButton } from '@ohif/ui';
import React, { useEffect, useState } from 'react';
import { SplitButton, ToolbarButton } from '@ohif/ui';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
function ToolbarSplitButtonWithServices({
isRadio,
isAction,
groupId,
primary,
secondary,
@@ -16,123 +13,35 @@ function ToolbarSplitButtonWithServices({
}) {
const { toolbarService } = servicesManager?.services;
const handleItemClick = (item, index) => {
const { id, type, commands } = item;
onInteraction({
groupId,
itemId: id,
interactionType: type,
commands,
});
setState(state => ({
...state,
primary: !isAction && isRadio ? { ...item, index } : state.primary,
isExpanded: false,
items: getSplitButtonItems(items).filter(item =>
isRadio && !isAction ? item.index !== index : true
),
}));
};
/* Bubbles up individual item clicks */
const getSplitButtonItems = items =>
items.map((item, index) => ({
...item,
index,
onClick: () => handleItemClick(item, index),
}));
const [buttonsState, setButtonState] = useState({
primaryToolId: '',
toggles: {},
groups: {},
});
const [state, setState] = useState({
primary,
items: getSplitButtonItems(items).filter(item =>
isRadio && !isAction ? item.id !== primary.id : true
),
});
const { primaryToolId, toggles } = buttonsState;
const isPrimaryToggle = state.primary.type === 'toggle';
const isPrimaryActive =
(state.primary.type === 'tool' && primaryToolId === state.primary.id) ||
(isPrimaryToggle && toggles[state.primary.id] === true);
const getSplitButtonItems = useCallback(
items =>
items.map((item, index) => ({
...item,
index,
onClick: () => {
onInteraction({
groupId,
itemId: item.id,
commands: item.commands,
});
},
})),
[]
);
const PrimaryButtonComponent =
toolbarService?.getButtonComponentForUIType(state.primary.uiType) ?? ToolbarButton;
toolbarService?.getButtonComponentForUIType(primary.uiType) ?? ToolbarButton;
useEffect(() => {
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
state => {
setButtonState({ ...state });
}
);
return () => {
unsubscribe();
};
}, [toolbarService]);
const updatedItems = state.items.map(item => {
const isActive = item.type === 'tool' && primaryToolId === item.id;
// We could have added the
// item.type === 'toggle' && toggles[item.id] === true
// too but that makes the button active when the toggle is active under it
// which feels weird
return {
...item,
isActive,
};
});
const DefaultListItemRenderer = ({ type, icon, label, t, id }) => {
const isActive = type === 'toggle' && toggles[id] === true;
return (
<div
className={classNames(
'hover:bg-primary-dark flex h-8 w-full flex-row items-center p-3',
'whitespace-pre text-base',
isActive && 'bg-primary-dark',
isActive
? 'text-[#348CFD]'
: 'text-common-bright hover:bg-primary-dark hover:text-primary-light'
)}
>
{icon && (
<span className="mr-4">
<Icon
name={icon}
className="h-5 w-5"
/>
</span>
)}
<span className="mr-5">{t(label)}</span>
</div>
);
};
const listItemRenderer = renderer || DefaultListItemRenderer;
const listItemRenderer = renderer;
return (
<SplitButton
isRadio={isRadio}
isAction={isAction}
primary={state.primary}
primary={primary}
secondary={secondary}
items={updatedItems}
items={getSplitButtonItems(items)}
groupId={groupId}
renderer={listItemRenderer}
isActive={isPrimaryActive || updatedItems.some(item => item.isActive)}
isToggle={isPrimaryToggle}
onInteraction={onInteraction}
Component={props => (
<PrimaryButtonComponent
@@ -146,11 +55,9 @@ function ToolbarSplitButtonWithServices({
ToolbarSplitButtonWithServices.propTypes = {
isRadio: PropTypes.bool,
isAction: PropTypes.bool,
groupId: PropTypes.string,
primary: PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired,
uiType: PropTypes.string,
}),
secondary: PropTypes.shape({
@@ -158,15 +65,17 @@ ToolbarSplitButtonWithServices.propTypes = {
icon: PropTypes.string.isRequired,
label: PropTypes.string,
tooltip: PropTypes.string.isRequired,
isActive: PropTypes.bool,
disabled: PropTypes.bool,
className: PropTypes.string,
}),
items: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired,
icon: PropTypes.string,
label: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
className: PropTypes.string,
})
),
renderer: PropTypes.func,
@@ -7,7 +7,7 @@ import { ErrorBoundary, UserPreferences, AboutModal, Header, useModal } from '@o
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import { useAppConfig } from '@state';
import Toolbar from '../Toolbar/Toolbar';
import { Toolbar } from '../Toolbar/Toolbar';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
+4 -12
View File
@@ -48,22 +48,13 @@ function ViewerLayout({
const getComponent = id => {
const entry = extensionManager.getModuleEntry(id);
if (!entry) {
if (!entry || !entry.component) {
throw new Error(
`${id} is not valid for an extension module. Please verify your configuration or ensure that the extension is properly registered. It's also possible that your mode is utilizing a module from an extension that hasn't been included in its dependencies (add the extension to the "extensionDependencies" array in your mode's index.js file)`
`${id} is not valid for an extension module or no component found from extension ${id}. Please verify your configuration or ensure that the extension is properly registered. It's also possible that your mode is utilizing a module from an extension that hasn't been included in its dependencies (add the extension to the "extensionDependencies" array in your mode's index.js file). Check the reference string to the extension in your Mode configuration`
);
}
let content;
if (entry && entry.component) {
content = entry.component;
} else {
throw new Error(
`No component found from extension ${id}. Check the reference string to the extension in your Mode configuration`
);
}
return { entry, content };
return { entry, content: entry.component };
};
const getPanelData = id => {
@@ -76,6 +67,7 @@ function ViewerLayout({
label: entry.label,
name: entry.name,
content,
contexts: entry.contexts,
};
};
+1 -79
View File
@@ -18,6 +18,7 @@ export type HangingProtocolParams = {
stageIndex?: number;
activeStudyUID?: string;
stageId?: string;
reset?: false;
};
export type UpdateViewportDisplaySetParams = {
@@ -107,41 +108,6 @@ const commandsModule = ({
measurementService.clear();
},
/**
* Toggles off all tools which contain a commandName of setHangingProtocol
* or toggleHangingProtocol, and which match/don't match the protocol id/stage
*/
toggleHpTools: () => {
const {
protocol,
stageIndex: toggleStageIndex,
stage,
} = hangingProtocolService.getActiveProtocol();
const enableListener = button => {
if (!button.id) {
return;
}
const { commands, items, primary } = button.props || button;
if (primary) {
enableListener(primary);
}
if (items) {
items.forEach(enableListener);
}
const hpCommand = commands?.find?.(isHangingProtocolCommand);
if (!hpCommand) {
return;
}
const { protocolId, stageIndex, stageId } = hpCommand.commandOptions;
const isActive =
(!protocolId || protocolId === protocol.id) &&
(stageIndex === undefined || stageIndex === toggleStageIndex) &&
(!stageId || stageId === stage.id);
toolbarService.setToggled(button.id, isActive);
};
Object.values(toolbarService.getButtons()).forEach(enableListener);
},
/**
* Sets the specified protocol
* 1. Records any existing state using the viewport grid service
@@ -173,14 +139,12 @@ const commandsModule = ({
stageIndex,
reset = false,
}: HangingProtocolParams): boolean => {
const primaryToolBeforeHPChange = toolbarService.getActivePrimaryTool();
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();
const { protocol: oldProtocol } = hangingProtocolService.getActiveProtocol();
const stateSyncReduce = reuseCachedLayouts(state, hangingProtocolService, stateSyncService);
const { hangingProtocolStageIndexMap, viewportGridStore, displaySetSelectorMap } =
stateSyncReduce;
@@ -243,33 +207,9 @@ const commandsModule = ({
`${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0`
];
stateSyncService.store(stateSyncReduce);
// This is a default action applied
actions.toggleHpTools();
// try to use the same tool in the new hanging protocol stage
const primaryButton = toolbarService.getButton(primaryToolBeforeHPChange);
if (primaryButton) {
// is there any type of interaction on this button, if not it might be in the
// items. This is a bit of a hack, but it works for now.
let interactionType = primaryButton.props?.interactionType;
if (!interactionType && primaryButton.props?.items) {
const firstItem = primaryButton.props.items[0];
interactionType = firstItem.props?.interactionType || firstItem.props?.type;
}
if (interactionType) {
toolbarService.recordInteraction({
interactionType,
...primaryButton.props,
});
}
}
return true;
} catch (e) {
console.error(e);
actions.toggleHpTools();
uiNotificationService.show({
title: 'Apply Hanging Protocol',
message: 'The hanging protocol could not be applied.',
@@ -639,56 +579,38 @@ const commandsModule = ({
},
clearMeasurements: {
commandFn: actions.clearMeasurements,
storeContexts: [],
options: {},
},
displayNotification: {
commandFn: actions.displayNotification,
storeContexts: [],
options: {},
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
storeContexts: [],
options: {},
},
toggleHangingProtocol: {
commandFn: actions.toggleHangingProtocol,
storeContexts: [],
options: {},
},
navigateHistory: {
commandFn: actions.navigateHistory,
storeContexts: [],
options: {},
},
nextStage: {
commandFn: actions.deltaStage,
storeContexts: [],
options: { direction: 1 },
},
previousStage: {
commandFn: actions.deltaStage,
storeContexts: [],
options: { direction: -1 },
},
setViewportGridLayout: {
commandFn: actions.setViewportGridLayout,
storeContexts: [],
options: {},
},
toggleOneUp: {
commandFn: actions.toggleOneUp,
storeContexts: [],
options: {},
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
updateViewportDisplaySet: {
commandFn: actions.updateViewportDisplaySet,
storeContexts: [],
options: {},
},
};
@@ -93,8 +93,8 @@ export default function getCustomizationModule({ servicesManager, extensionManag
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
? this.contentF(props)
: null;
if (!value) {
return null;
}
+45 -17
View File
@@ -1,39 +1,67 @@
import ToolbarDivider from './Toolbar/ToolbarDivider';
import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector';
import ToolbarSplitButtonWithServices from './Toolbar/ToolbarSplitButtonWithServices';
import ToolbarButtonWithServices from './Toolbar/ToolbarButtonWithServices';
import ToolbarButtonGroupWithServices from './Toolbar/ToolbarButtonGroupWithServices';
import { ToolbarButton } from '@ohif/ui';
const getClassName = isToggled => {
return {
className: isToggled
? '!text-primary-active'
: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
};
export default function getToolbarModule({ commandsManager, servicesManager }) {
const { cineService } = servicesManager.services;
return [
{
name: 'ohif.radioGroup',
defaultComponent: ToolbarButton,
},
{
name: 'ohif.divider',
defaultComponent: ToolbarDivider,
clickHandler: () => {},
},
{
name: 'ohif.action',
defaultComponent: ToolbarButtonWithServices,
clickHandler: () => {},
},
{
name: 'ohif.radioGroup',
defaultComponent: ToolbarButtonWithServices,
clickHandler: () => {},
},
{
name: 'ohif.splitButton',
defaultComponent: ToolbarSplitButtonWithServices,
clickHandler: () => {},
},
{
name: 'ohif.layoutSelector',
defaultComponent: ToolbarLayoutSelectorWithServices,
clickHandler: (evt, clickedBtn, btnSectionName) => {},
},
{
name: 'ohif.toggle',
defaultComponent: ToolbarButtonWithServices,
clickHandler: () => {},
name: 'ohif.buttonGroup',
defaultComponent: ToolbarButtonGroupWithServices,
},
{
name: 'evaluate.group.promoteToPrimary',
evaluate: ({ viewportId, button, itemId }) => {
const { items } = button.props;
if (!itemId) {
return {
primary: button.props.primary,
items,
};
}
// other wise we can move the clicked tool to the primary button
const clickedItemProps = items.find(item => item.id === itemId || item.itemId === itemId);
return {
primary: clickedItemProps,
items,
};
},
},
{
name: 'evaluate.cine',
evaluate: () => {
const isToggled = cineService.getState().isCineEnabled;
return getClassName(isToggled);
},
},
];
}
+51 -3
View File
@@ -10,8 +10,13 @@ const metadataProvider = classes.MetadataProvider;
* @param {Object} servicesManager
* @param {Object} configuration
*/
export default function init({ servicesManager, configuration = {} }): void {
const { stateSyncService } = servicesManager.services;
export default function init({ servicesManager, configuration = {}, commandsManager }): void {
const { stateSyncService, toolbarService, cineService, viewportGridService } =
servicesManager.services;
toolbarService.registerEventForToolbarUpdate(cineService, [
cineService.EVENTS.CINE_STATE_CHANGED,
]);
// Add
DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.INSTANCES_ADDED, handlePETImageMetadata);
@@ -24,6 +29,10 @@ export default function init({ servicesManager, configuration = {} }): void {
// Used to recover manual changes to the layout of a stage.
stateSyncService.register('viewportGridStore', { clearOnModeExit: true });
// uiStateStore is a sync state which stores the relevant
// UI state for the viewer
stateSyncService.register('uiStateStore', { clearOnModeExit: true });
// displaySetSelectorMap stores a map from
// `<activeStudyUID>:<displaySetSelectorId>:<matchOffset>` to
// a displaySetInstanceUID, used to display named display sets in
@@ -38,7 +47,7 @@ export default function init({ servicesManager, configuration = {} }): void {
});
// Stores a map from the to be applied hanging protocols `<activeStudyUID>:<protocolId>`
// to the previously applied hanging protolStageIndexMap key, in order to toggle
// to the previously applied hanging protocolStageIndexMap key, in order to toggle
// off the applied protocol and remember the old state.
stateSyncService.register('toggleHangingProtocol', { clearOnModeExit: true });
@@ -46,6 +55,45 @@ export default function init({ servicesManager, configuration = {} }): void {
// changes numRows and numCols, the viewports can be remembers and then replaced
// afterwards.
stateSyncService.register('viewportsByPosition', { clearOnModeExit: true });
// Function to process and subscribe to events for a given set of commands and listeners
const subscribeToEvents = listeners => {
Object.entries(listeners).forEach(([event, commands]) => {
const supportedEvents = [
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
viewportGridService.EVENTS.VIEWPORTS_READY,
];
if (supportedEvents.includes(event)) {
viewportGridService.subscribe(event, eventData => {
const viewportId = eventData?.viewportId ?? viewportGridService.getActiveViewportId();
commandsManager.run(commands, { viewportId });
});
}
});
};
toolbarService.subscribe(toolbarService.EVENTS.TOOL_BAR_MODIFIED, state => {
const { buttons } = state;
for (const [id, button] of Object.entries(buttons)) {
const { groupId, items, listeners } = button.props;
// Handle group items' listeners
if (groupId && items) {
items.forEach(item => {
if (item.listeners) {
subscribeToEvents(item.listeners);
}
});
}
// Handle button listeners
if (listeners) {
subscribeToEvents(listeners);
}
}
});
}
const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => {
@@ -11,16 +11,6 @@ import dcmjs from 'dcmjs';
import cleanDenaturalizedDataset from './utils/cleanDenaturalizedDataset';
import MicroscopyService from './services/MicroscopyService';
function transformImageTypeUnnaturalized(entry) {
if (entry.vr === 'CS') {
return {
vr: 'US',
Value: entry.Value[0].split('\\'),
};
}
return entry;
}
class DicomMicroscopyViewport extends Component {
state = {
error: null as any,
@@ -250,7 +240,6 @@ class DicomMicroscopyViewport extends Component {
componentDidMount() {
const { displaySets, viewportOptions } = this.props;
const { viewportId } = viewportOptions;
// Todo-rename: this is always getting the 0
const displaySet = displaySets[0];
this.installOpenLayersRenderer(this.container.current, displaySet).then(() => {
@@ -124,7 +124,7 @@ export default function getCommandsModule({
}
// overview
const { activeViewportId, viewports } = viewportGridService.getState();
const { activeViewportId } = viewportGridService.getState();
microscopyService.toggleOverviewMap(activeViewportId);
},
toggleAnnotations: () => {
@@ -135,28 +135,18 @@ export default function getCommandsModule({
const definitions = {
deleteMeasurement: {
commandFn: actions.deleteMeasurement,
storeContexts: [] as any[],
options: {},
},
setLabel: {
commandFn: actions.setLabel,
storeContexts: [] as any[],
options: {},
},
setToolActive: {
commandFn: actions.setToolActive,
storeContexts: [] as any[],
options: {},
},
toggleOverlays: {
commandFn: actions.toggleOverlays,
storeContexts: [] as any[],
options: {},
},
toggleAnnotations: {
commandFn: actions.toggleAnnotations,
storeContexts: [] as any[],
options: {},
},
};
+44 -2
View File
@@ -2,6 +2,7 @@ import { id } from './id';
import React, { Suspense, useMemo } from 'react';
import getPanelModule from './getPanelModule';
import getCommandsModule from './getCommandsModule';
import { Types } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui';
import getDicomMicroscopySopClassHandler from './DicomMicroscopySopClassHandler';
@@ -23,14 +24,14 @@ const MicroscopyViewport = props => {
/**
* You can remove any of the following modules if you don't need them.
*/
export default {
const extension: Types.Extensions.Extension = {
/**
* Only required property. Should be a unique value across all extensions.
* You ID can be anything you want, but it should be unique.
*/
id,
async preRegistration({ servicesManager, commandsManager, configuration = {}, appConfig }) {
async preRegistration({ servicesManager }) {
servicesManager.registerService(MicroscopyService.REGISTRATION(servicesManager));
},
@@ -90,6 +91,45 @@ export default {
];
},
getToolbarModule({ servicesManager }) {
return [
{
name: 'evaluate.microscopyTool',
evaluate: ({ button }) => {
const { microscopyService } = servicesManager.services;
const activeInteractions = microscopyService.getActiveInteractions();
const isPrimaryActive = activeInteractions.find(interactions => {
const sameMouseButton = interactions[1].bindings.mouseButtons.includes('left');
if (!sameMouseButton) {
return false;
}
const notDraw = interactions[0] !== 'draw';
// there seems to be a custom logic for draw tool for some reason
return notDraw
? interactions[0] === button.id
: interactions[1].geometryType === button.id;
});
return {
disabled: false,
className: isPrimaryActive
? '!text-black bg-primary-light'
: '!text-common-bright hover:!bg-primary-dark hover:!text-primary-light',
// Todo: isActive right now is used for nested buttons where the primary
// button needs to be fully rounded (vs partial rounded) when active
// otherwise it does not have any other use
isActive: isPrimaryActive,
};
},
},
];
},
/**
* SopClassHandlerModule should provide a list of sop class handlers that will be
* available in OHIF for Modes to consume and use to create displaySets from Series.
@@ -113,3 +153,5 @@ export default {
getCommandsModule,
};
export default extension;
@@ -573,6 +573,10 @@ export default class MicroscopyService extends PubSubService {
this.activeInteractions = interactions;
}
getActiveInteractions() {
return this.activeInteractions;
}
/**
* Triggers the relabelling process for the given RoiAnnotation instance, by
* publishing the RELABEL event to notify the subscribers
-12
View File
@@ -41,18 +41,6 @@ const dicomPDFExtension = {
return [{ name: 'dicom-pdf', component: ExtendedOHIFCornerstonePdfViewport }];
},
// getCommandsModule({ servicesManager }) {
// return {
// definitions: {
// setToolActive: {
// commandFn: () => null,
// storeContexts: [],
// options: {},
// },
// },
// defaultContext: 'ACTIVE_VIEWPORT::PDF',
// };
// },
getSopClassHandlerModule,
};
-13
View File
@@ -42,19 +42,6 @@ const dicomVideoExtension = {
return [{ name: 'dicom-video', component: ExtendedOHIFCornerstoneVideoViewport }];
},
// getCommandsModule({ servicesManager }) {
// return {
// definitions: {
// setToolActive: {
// commandFn: ({ toolName, element }) => {
// },
// storeContexts: [],
// options: {},
// },
// },
// defaultContext: 'ACTIVE_VIEWPORT::VIDEO',
// };
// },
getSopClassHandlerModule,
};
@@ -5,6 +5,12 @@
function getImageSrcFromImageId(cornerstone, imageId) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
// Note: the default width and height of the canvas is 300x150
// but we need to set the width and height to the same number since
// the thumbnails are usually square and we want to maintain the aspect ratio
canvas.width = 128 / window.devicePixelRatio;
canvas.height = 128 / window.devicePixelRatio;
cornerstone.utilities
.loadImageToCanvas({ canvas, imageId })
.then(imageId => {
@@ -330,12 +330,12 @@ function _getStatusComponent(isTracked) {
<div className="ml-4 flex">
<span className="text-common-light text-base">
{isTracked ? (
<>
{t('Series is tracked and can be viewed in the measurement panel')}
</>
<>{t('Series is tracked and can be viewed in the measurement panel')}</>
) : (
<>
{t('Measurements for untracked series will not be shown in the measurements panel')}
{t(
'Measurements for untracked series will not be shown in the measurements panel'
)}
</>
)}
</span>
+6 -17
View File
@@ -109,23 +109,6 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) {
throw new Error('No ptDisplaySet found');
}
const toolGroupIds = toolGroupService.getToolGroupIds();
// Todo: we don't have a proper way to perform a toggle command and update the
// state for the toolbar, so here, we manually toggle the toolbar
// Todo: Crosshairs have bugs for the camera reset currently, so we need to
// force turn it off before we update the metadata
toolGroupIds.forEach(toolGroupId => {
commandsManager.runCommand('toggleCrosshairs', {
toolGroupId,
toggledState: false,
});
});
toolbarService.state.toggles['Crosshairs'] = false;
toolbarService._broadcastEvent(toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED);
// metadata should be dcmjs naturalized
DicomMetadataStore.updateMetadataForSeries(
ptDisplaySet.StudyInstanceUID,
@@ -135,6 +118,12 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) {
// update the displaySets
displaySetService.setDisplaySetMetadataInvalidated(ptDisplaySet.displaySetInstanceUID);
// Crosshair position depends on the metadata values such as the positioning interaction
// between series, so when the metadata is updated, the crosshairs need to be reset.
setTimeout(() => {
commandsManager.runCommand('resetCrosshairs');
}, 0);
}
return (
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
-32
View File
@@ -530,83 +530,51 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
const definitions = {
setEndSliceForROIThresholdTool: {
commandFn: actions.setEndSliceForROIThresholdTool,
storeContexts: [],
options: {},
},
setStartSliceForROIThresholdTool: {
commandFn: actions.setStartSliceForROIThresholdTool,
storeContexts: [],
options: {},
},
getMatchingPTDisplaySet: {
commandFn: actions.getMatchingPTDisplaySet,
storeContexts: [],
options: {},
},
getPTMetadata: {
commandFn: actions.getPTMetadata,
storeContexts: [],
options: {},
},
createNewLabelmapFromPT: {
commandFn: actions.createNewLabelmapFromPT,
storeContexts: [],
options: {},
},
setSegmentationActiveForToolGroups: {
commandFn: actions.setSegmentationActiveForToolGroups,
storeContexts: [],
options: {},
},
thresholdSegmentationByRectangleROITool: {
commandFn: actions.thresholdSegmentationByRectangleROITool,
storeContexts: [],
options: {},
},
getTotalLesionGlycolysis: {
commandFn: actions.getTotalLesionGlycolysis,
storeContexts: [],
options: {},
},
calculateSuvPeak: {
commandFn: actions.calculateSuvPeak,
storeContexts: [],
options: {},
},
getLesionStats: {
commandFn: actions.getLesionStats,
storeContexts: [],
options: {},
},
calculateTMTV: {
commandFn: actions.calculateTMTV,
storeContexts: [],
options: {},
},
exportTMTVReportCSV: {
commandFn: actions.exportTMTVReportCSV,
storeContexts: [],
options: {},
},
createTMTVRTReport: {
commandFn: actions.createTMTVRTReport,
storeContexts: [],
options: {},
},
getSegmentationCSVReport: {
commandFn: actions.getSegmentationCSVReport,
storeContexts: [],
options: {},
},
exportRTReportForAnnotations: {
commandFn: actions.exportRTReportForAnnotations,
storeContexts: [],
options: {},
},
setFusionPTColormap: {
commandFn: actions.setFusionPTColormap,
storeContexts: [],
options: {},
},
};
+1 -2
View File
@@ -14,7 +14,7 @@ const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
* @param {Object} configuration
* @param {Object|Array} configuration.csToolsConfig
*/
export default function init({ servicesManager, extensionManager }) {
export default function init({ servicesManager }) {
const { measurementService, displaySetService, cornerstoneViewportService } =
servicesManager.services;
@@ -38,6 +38,5 @@ export default function init({ servicesManager, extensionManager }) {
RectangleROIStartEndThreshold.toAnnotation,
RectangleROIStartEndThreshold.toMeasurement
);
colormaps.forEach(registerColormap);
}