diff --git a/.circleci/config.yml b/.circleci/config.yml index 30c35af46..88ea0bcd3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,7 @@ version: 2.1 ## orbs: codecov: codecov/codecov@1.0.5 - cypress: cypress-io/cypress@3.2.1 + cypress: cypress-io/cypress@3.3.1 executors: cypress-custom: diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml deleted file mode 100644 index 7373affc3..000000000 --- a/.github/workflows/codespell.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: Codespell - -on: - push: - branches: [master] - pull_request: - branches: [master] - -permissions: - contents: read - -jobs: - codespell: - name: Check for spelling errors - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Codespell - uses: codespell-project/actions-codespell@v2 diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts index 1926ee9f1..c311e82aa 100644 --- a/extensions/cornerstone-dicom-seg/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -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', }; }; diff --git a/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx index e626c87d7..9227502af 100644 --- a/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx +++ b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx @@ -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 ( <> - { + // 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; +} diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx index bb6a6d4b1..5fe0efef5 100644 --- a/extensions/cornerstone-dicom-seg/src/index.tsx +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -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 ( { - 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 ( - 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 ( -
-
-
Threshold
- -
- ); - }, - }, - ], - }, - ]} - /> - ); -} - -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; diff --git a/extensions/cornerstone-dicom-sr/src/commandsModule.js b/extensions/cornerstone-dicom-sr/src/commandsModule.js index 33e8a869f..2f355bc09 100644 --- a/extensions/cornerstone-dicom-sr/src/commandsModule.js +++ b/extensions/cornerstone-dicom-sr/src/commandsModule.js @@ -128,13 +128,9 @@ const commandsModule = props => { const definitions = { downloadReport: { commandFn: actions.downloadReport, - storeContexts: [], - options: {}, }, storeMeasurements: { commandFn: actions.storeMeasurements, - storeContexts: [], - options: {}, }, }; diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 5058a1ca5..6f62acac8 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -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", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index ce051e999..389672f05 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -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] diff --git a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx index 8745fdec7..0858f9089 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx @@ -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 ( -
- W: - {windowWidth.toFixed(0)} - L: - {windowCenter.toFixed(0)} -
- ); -} - -/** - * Zoom Level Overlay item - */ -function ZoomOverlayItem({ scale, customization }: OverlayItemProps) { - return ( -
- Zoom: - {scale.toFixed(2)}x -
- ); -} - -/** - * Instance Number Overlay Item - */ -function InstanceNumberOverlayItem({ - instanceNumber, - imageSliceData, - customization, -}: OverlayItemProps) { - const { imageIndex, numberOfSlices } = imageSliceData; - - return ( -
- I: - - {instanceNumber !== undefined && instanceNumber !== null - ? `${instanceNumber} (${imageIndex + 1}/${numberOfSlices})` - : `${imageIndex + 1}/${numberOfSlices}`} - -
- ); -} +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 ; - } else if (item.customizationType === 'ohif.overlayItem.zoomLevel') { - return ; - } else if (item.customizationType === 'ohif.overlayItem.instanceNumber') { - return ; + if (!item) { + return null; + } + + const { customizationType } = item; + const OverlayItemComponent = OverlayItemComponents[customizationType]; + + if (OverlayItemComponent) { + return ; } 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) => ( -
{_renderOverlayItem(item)}
- ))} - - ); - }, [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) => ( -
{_renderOverlayItem(item)}
- ))} - - ); - }, [topRightCustomization, _renderOverlayItem]); - - const getBottomLeftContent = useCallback(() => { - const items = bottomLeftCustomization?.items || []; - return ( - <> - {items.map((item, i) => ( -
{_renderOverlayItem(item)}
- ))} - - ); - }, [bottomLeftCustomization, _renderOverlayItem]); - - const getBottomRightContent = useCallback(() => { - const items = bottomRightCustomization?.items || []; - return ( - <> - {items.map((item, i) => ( -
{_renderOverlayItem(item)}
- ))} - - ); - }, [bottomRightCustomization, _renderOverlayItem]); + return ( + <> + {items.map((item, index) => ( +
+ {item?.condition + ? item.condition() + ? _renderOverlayItem(item) + : null + : _renderOverlayItem(item)} +
+ ))} + + ); + }, + [_renderOverlayItem] + ); return ( { + 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 ( +
+ W: + {windowWidth.toFixed(0)} + L: + {windowCenter.toFixed(0)} +
+ ); +} + +/** + * Zoom Level Overlay item + */ +function ZoomOverlayItem({ scale, customization }: OverlayItemProps) { + return ( +
+ Zoom: + {scale.toFixed(2)}x +
+ ); +} + +/** + * Instance Number Overlay Item + */ +function InstanceNumberOverlayItem({ + instanceNumber, + imageSliceData, + customization, +}: OverlayItemProps) { + const { imageIndex, numberOfSlices } = imageSliceData; + + return ( +
+ I: + + {instanceNumber !== undefined && instanceNumber !== null + ? `${instanceNumber} (${imageIndex + 1}/${numberOfSlices})` + : `${imageIndex + 1}/${numberOfSlices}`} + +
+ ); +} + CustomizableViewportOverlay.propTypes = { viewportData: PropTypes.object, imageIndex: PropTypes.number, diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportOverlay.tsx deleted file mode 100644 index a33696cb5..000000000 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOverlay.tsx +++ /dev/null @@ -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 ( -
- W: - {windowWidth.toFixed(0)} - L: - {windowCenter.toFixed(0)} -
- ); - } - - if (activeTools.includes('Zoom')) { - return ( -
- Zoom: - {scale.toFixed(2)}x -
- ); - } - - 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 ( -
- I: - - {instanceNumber !== undefined - ? `${instanceNumber} (${imageIndex + 1}/${numberOfSlices})` - : `${imageIndex + 1}/${numberOfSlices}`} - -
- ); - }, [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 ( - - ); -} - -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; diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index e32155f99..b63aee091 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -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 { diff --git a/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx b/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx index be007920e..3880f4247 100644 --- a/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx +++ b/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx @@ -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, }) diff --git a/extensions/cornerstone/src/getToolbarModule.tsx b/extensions/cornerstone/src/getToolbarModule.tsx new file mode 100644 index 000000000..7c1209836 --- /dev/null +++ b/extensions/cornerstone/src/getToolbarModule.tsx @@ -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; +} diff --git a/extensions/cornerstone/src/hps/mpr.ts b/extensions/cornerstone/src/hps/mpr.ts index 4c625c731..c9a9eea82 100644 --- a/extensions/cornerstone/src/hps/mpr.ts +++ b/extensions/cornerstone/src/hps/mpr.ts @@ -22,11 +22,6 @@ export const mpr: Types.HangingProtocol.Protocol = { }, ], // Turns off crosshairs when switching out of MPR mode - onProtocolExit: [ - { - commandName: 'cleanUpCrosshairs', - }, - ], }, displaySetSelectors: { activeDisplaySet: { diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index ac53c8f0f..808f63afa 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -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 => { diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 8e1ede126..6d7f3ef7a 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -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 { // 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() { diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index f71dbff5d..c073f3f13 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -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, }); diff --git a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts index f93075e1c..0c1536be7 100644 --- a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts +++ b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts @@ -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) ); } diff --git a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts index 3a85342b3..438fca1bd 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -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. diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 96050311c..b6ff441e8 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -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(); diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index ca0de98b7..e06488d3d 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -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') { diff --git a/extensions/cornerstone/src/utils/imageSliceSync/toggleImageSliceSync.ts b/extensions/cornerstone/src/utils/imageSliceSync/toggleImageSliceSync.ts index 38e5bdfed..1a8e8cd78 100644 --- a/extensions/cornerstone/src/utils/imageSliceSync/toggleImageSliceSync.ts +++ b/extensions/cornerstone/src/utils/imageSliceSync/toggleImageSliceSync.ts @@ -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, }); diff --git a/extensions/cornerstone/src/utils/toggleVOISliceSync.ts b/extensions/cornerstone/src/utils/toggleVOISliceSync.ts new file mode 100644 index 000000000..0677cff37 --- /dev/null +++ b/extensions/cornerstone/src/utils/toggleVOISliceSync.ts @@ -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; + }, {}); +} diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx index 752929063..2e0a73819 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx @@ -60,7 +60,6 @@ export default class ContextMenuController { return; } - console.log('Getting items from', menus); const items = ContextMenuItemsBuilder.getMenuItems( selectorProps || contextMenuProps, event, diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts index 8b8b98532..88860b2a1 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts @@ -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; } diff --git a/extensions/default/src/Panels/getImageSrcFromImageId.js b/extensions/default/src/Panels/getImageSrcFromImageId.js index 5cda3dff0..fbbbbe993 100644 --- a/extensions/default/src/Panels/getImageSrcFromImageId.js +++ b/extensions/default/src/Panels/getImageSrcFromImageId.js @@ -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 => { diff --git a/extensions/default/src/Toolbar/Toolbar.tsx b/extensions/default/src/Toolbar/Toolbar.tsx index f3812ff5b..4852026e9 100644 --- a/extensions/default/src/Toolbar/Toolbar.tsx +++ b/extensions/default/src/Toolbar/Toolbar.tsx @@ -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 = ( + + ); + + return disabled ? ( + +
{tool}
+
+ ) : (
- + {tool}
); })} diff --git a/extensions/default/src/Toolbar/ToolbarButtonGroupWithServices.tsx b/extensions/default/src/Toolbar/ToolbarButtonGroupWithServices.tsx new file mode 100644 index 000000000..42180eaec --- /dev/null +++ b/extensions/default/src/Toolbar/ToolbarButtonGroupWithServices.tsx @@ -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) => ( + { + 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 {getSplitButtonItems(items)}; +} + +export default ToolbarButtonGroupWithServices; diff --git a/extensions/default/src/Toolbar/ToolbarButtonWithServices.tsx b/extensions/default/src/Toolbar/ToolbarButtonWithServices.tsx deleted file mode 100644 index 32b7fb51e..000000000 --- a/extensions/default/src/Toolbar/ToolbarButtonWithServices.tsx +++ /dev/null @@ -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 ( - - ); -} - -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; diff --git a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx index 430cd5aba..27202ed0a 100644 --- a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx +++ b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx @@ -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 ( ); } -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 }) { ) } diff --git a/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx b/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx index a8943b16f..5dfee4ad5 100644 --- a/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx +++ b/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx @@ -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 ( -
- {icon && ( - - - - )} - {t(label)} -
- ); - }; - - const listItemRenderer = renderer || DefaultListItemRenderer; + const listItemRenderer = renderer; return ( item.isActive)} - isToggle={isPrimaryToggle} onInteraction={onInteraction} Component={props => ( { 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, }; }; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index b58c54083..4658f878a 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -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: {}, }, }; diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index 1da3f1871..206900338 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -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; } diff --git a/extensions/default/src/getToolbarModule.tsx b/extensions/default/src/getToolbarModule.tsx index eacdffd7b..00d010440 100644 --- a/extensions/default/src/getToolbarModule.tsx +++ b/extensions/default/src/getToolbarModule.tsx @@ -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); + }, }, ]; } diff --git a/extensions/default/src/init.ts b/extensions/default/src/init.ts index f77bc7ed0..d70cfa18c 100644 --- a/extensions/default/src/init.ts +++ b/extensions/default/src/init.ts @@ -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 // `::` 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 `:` - // 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 }) => { diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx index 39b487358..fd44e21f7 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx @@ -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(() => { diff --git a/extensions/dicom-microscopy/src/getCommandsModule.ts b/extensions/dicom-microscopy/src/getCommandsModule.ts index 756c4fd5c..02d7ee8b8 100644 --- a/extensions/dicom-microscopy/src/getCommandsModule.ts +++ b/extensions/dicom-microscopy/src/getCommandsModule.ts @@ -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: {}, }, }; diff --git a/extensions/dicom-microscopy/src/index.tsx b/extensions/dicom-microscopy/src/index.tsx index 486736407..bd26b5c3e 100644 --- a/extensions/dicom-microscopy/src/index.tsx +++ b/extensions/dicom-microscopy/src/index.tsx @@ -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; diff --git a/extensions/dicom-microscopy/src/services/MicroscopyService.ts b/extensions/dicom-microscopy/src/services/MicroscopyService.ts index 78ba545c2..0f6005f5d 100644 --- a/extensions/dicom-microscopy/src/services/MicroscopyService.ts +++ b/extensions/dicom-microscopy/src/services/MicroscopyService.ts @@ -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 diff --git a/extensions/dicom-pdf/src/index.tsx b/extensions/dicom-pdf/src/index.tsx index ab3d33cf4..a655cbc50 100644 --- a/extensions/dicom-pdf/src/index.tsx +++ b/extensions/dicom-pdf/src/index.tsx @@ -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, }; diff --git a/extensions/dicom-video/src/index.tsx b/extensions/dicom-video/src/index.tsx index a5fcd40d1..0fde8ca82 100644 --- a/extensions/dicom-video/src/index.tsx +++ b/extensions/dicom-video/src/index.tsx @@ -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, }; diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js index 60a31499b..1fdf18497 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js @@ -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 => { diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index 759460144..a2026edab 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -330,12 +330,12 @@ function _getStatusComponent(isTracked) {
{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' + )} )} diff --git a/extensions/tmtv/src/Panels/PanelPetSUV.tsx b/extensions/tmtv/src/Panels/PanelPetSUV.tsx index f0a467eef..618ff051c 100644 --- a/extensions/tmtv/src/Panels/PanelPetSUV.tsx +++ b/extensions/tmtv/src/Panels/PanelPetSUV.tsx @@ -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 (
diff --git a/extensions/tmtv/src/commandsModule.js b/extensions/tmtv/src/commandsModule.js index 73409ee41..32b368b8e 100644 --- a/extensions/tmtv/src/commandsModule.js +++ b/extensions/tmtv/src/commandsModule.js @@ -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: {}, }, }; diff --git a/extensions/tmtv/src/init.js b/extensions/tmtv/src/init.js index 6eaa1c25e..99d4135b6 100644 --- a/extensions/tmtv/src/init.js +++ b/extensions/tmtv/src/init.js @@ -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); } diff --git a/modes/basic-dev-mode/src/index.js b/modes/basic-dev-mode/src/index.js index bcedc4aca..4bc20e843 100644 --- a/modes/basic-dev-mode/src/index.js +++ b/modes/basic-dev-mode/src/index.js @@ -89,39 +89,8 @@ function modeFactory({ modeConfiguration }) { // disabled }; - const toolGroupId = 'default'; - toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); + toolGroupService.createToolGroupAndAddTools('default', tools); - let unsubscribe; - - const activateTool = () => { - toolbarService.recordInteraction({ - groupId: 'WindowLevel', - interactionType: 'tool', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - }); - - // We don't need to reset the active tool whenever a viewport is getting - // added to the toolGroup. - unsubscribe(); - }; - - // Since we only have one viewport for the basic cs3d mode and it has - // only one hanging protocol, we can just use the first viewport - ({ unsubscribe } = toolGroupService.subscribe( - toolGroupService.EVENTS.VIEWPORT_ADDED, - activateTool - )); - - toolbarService.init(extensionManager); toolbarService.addButtons(toolbarButtons); toolbarService.createButtonSection('primary', [ 'MeasurementTools', diff --git a/modes/basic-dev-mode/src/toolbarButtons.js b/modes/basic-dev-mode/src/toolbarButtons.js index 995e4dea6..638e73b72 100644 --- a/modes/basic-dev-mode/src/toolbarButtons.js +++ b/modes/basic-dev-mode/src/toolbarButtons.js @@ -1,47 +1,16 @@ -// TODO: torn, can either bake this here; or have to create a whole new button type -// Only ways that you can pass in a custom React component for render :l import { - // ExpandableToolbarButton, - // ListMenu, WindowLevelMenuItem, } from '@ohif/ui'; -import { defaults } from '@ohif/core'; +import { defaults, ToolbarService } from '@ohif/core'; +import type { Button } from '@ohif/core/types'; const { windowLevelPresets } = defaults; -/** - * - * @param {*} type - 'tool' | 'action' | 'toggle' - * @param {*} id - * @param {*} icon - * @param {*} label - */ -function _createButton(type, id, icon, label, commands, tooltip) { - return { - id, - icon, - label, - type, - commands, - tooltip, - }; -} -const _createActionButton = _createButton.bind(null, 'action'); -const _createToggleButton = _createButton.bind(null, 'toggle'); -const _createToolButton = _createButton.bind(null, 'tool'); - -/** - * - * @param {*} preset - preset number (from above import) - * @param {*} title - * @param {*} subtitle - */ function _createWwwcPreset(preset, title, subtitle) { return { id: preset.toString(), title, subtitle, - type: 'action', commands: [ { commandName: 'setWindowLevel', @@ -54,147 +23,91 @@ function _createWwwcPreset(preset, title, subtitle) { }; } -const toolbarButtons = [ - // Measurement +function _createSetToolActiveCommands(toolName, toolGroupIds = ['default', 'mpr', ]) { + return toolGroupIds.map(toolGroupId => ({ + commandName: 'setToolActive', + commandOptions: { + toolGroupId, + toolName, + }, + context: 'CORNERSTONE', + })); +} + +const toolbarButtons: Button[] = [ { id: 'MeasurementTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'MeasurementTools', - isRadio: true, // ? - // Switch? - primary: _createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Length', - }, - context: 'CORNERSTONE', - }, - ], - 'Length' - ), + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: ToolbarService.createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: _createSetToolActiveCommands('Length'), + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', - label: '', - isActive: true, tooltip: 'More Measure Tools', }, items: [ - _createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Length', - }, - context: 'CORNERSTONE', - }, - ], - 'Length Tool' - ), - _createToolButton( - 'Bidirectional', - 'tool-bidirectional', - 'Bidirectional', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Bidirectional', - }, - context: 'CORNERSTONE', - }, - ], - 'Bidirectional Tool' - ), - _createToolButton( - 'EllipticalROI', - 'tool-elipse', - 'Ellipse', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'EllipticalROI', - }, - context: 'CORNERSTONE', - }, - ], - 'Ellipse Tool' - ), - _createToolButton( - 'CircleROI', - 'tool-circle', - 'Circle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'CircleROI', - }, - context: 'CORNERSTONE', - }, - ], - 'Circle Tool' - ), + ToolbarService.createButton({ + id: 'Bidirectional', + icon: 'tool-bidirectional', + label: 'Bidirectional', + tooltip: 'Bidirectional Tool', + commands: _createSetToolActiveCommands('Bidirectional'), + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'EllipticalROI', + icon: 'tool-ellipse', + label: 'Ellipse', + tooltip: 'Ellipse ROI', + commands: _createSetToolActiveCommands('EllipticalROI'), + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'CircleROI', + icon: 'tool-circle', + label: 'Circle', + tooltip: 'Circle Tool', + commands: _createSetToolActiveCommands('CircleROI'), + evaluate: 'evaluate.cornerstoneTool', + }), ], }, }, - // Zoom.. { id: 'Zoom', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-zoom', label: 'Zoom', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Zoom', - }, - context: 'CORNERSTONE', - }, - ], + commands: _createSetToolActiveCommands('Zoom'), + evaluate: 'evaluate.cornerstoneTool', }, }, - // Window Level + Presets... { id: 'WindowLevel', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'WindowLevel', - primary: _createToolButton( - 'WindowLevel', - 'tool-window-level', - 'Window Level', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - 'Window Level' - ), + primary: ToolbarService.createButton({ + id: 'WindowLevel', + icon: 'tool-window-level', + label: 'Window Level', + tooltip: 'Window Level', + commands: _createSetToolActiveCommands('WindowLevel'), + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', - label: 'W/L Manual', - isActive: true, tooltip: 'W/L Presets', }, - isAction: true, // ? renderer: WindowLevelMenuItem, items: [ _createWwwcPreset(1, 'Soft tissue', '400 / 40'), @@ -205,157 +118,141 @@ const toolbarButtons = [ ], }, }, - // Pan... { id: 'Pan', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-move', label: 'Pan', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Pan', - }, - context: 'CORNERSTONE', - }, - ], + commands: _createSetToolActiveCommands('Pan'), + evaluate: 'evaluate.cornerstoneTool', }, }, { id: 'Capture', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { icon: 'tool-capture', label: 'Capture', - type: 'action', commands: [ { commandName: 'showDownloadViewportModal', - commandOptions: {}, context: 'CORNERSTONE', }, ], + evaluate: 'evaluate.action', + }, + }, + { + id: 'Layout', + uiType: 'ohif.layoutSelector', + props: { + rows: 3, + columns: 3, + evaluate: 'evaluate.action', + commands: [ + { + commandName: 'setViewportGridLayout', + }, + ], }, }, { - id: 'Layout', - type: 'ohif.layoutSelector', - }, - // More... - { - id: 'MoreTools', - type: 'ohif.splitButton', - props: { - isRadio: true, // ? - groupId: 'MoreTools', - primary: _createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ + id: 'MoreTools', + uiType: 'ohif.splitButton', + props: { + groupId: 'MoreTools', + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: ToolbarService.createButton({ + id: 'Reset', + icon: 'tool-reset', + label: 'Reset View', + tooltip: 'Reset View', + commands: [ + { + commandName: 'resetViewport', + context: 'CORNERSTONE', + }, + ], + evaluate: 'evaluate.action', + }), + secondary: { + icon: 'chevron-down', + tooltip: 'More Tools', + }, + items: [ + ToolbarService.createButton({ + id: 'Reset', + icon: 'tool-reset', + label: 'Reset View', + tooltip: 'Reset View', + commands: [ + { + commandName: 'resetViewport', + context: 'CORNERSTONE', + }, + ], + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'RotateRight', + icon: 'tool-rotate-right', + label: 'Rotate Right', + tooltip: 'Rotate Right +90', + commands: [ { - commandName: 'resetViewport', - commandOptions: {}, + commandName: 'rotateViewportCW', context: 'CORNERSTONE', }, ], - 'Reset' - ), - secondary: { - icon: 'chevron-down', - label: '', - isActive: true, - tooltip: 'More Tools', - }, - items: [ - _createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Reset' - ), - _createActionButton( - 'rotate-right', - 'tool-rotate-right', - 'Rotate Right', - [ - { - commandName: 'rotateViewportCW', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Rotate +90' - ), - _createActionButton( - 'flip-horizontal', - 'tool-flip-horizontal', - 'Flip Horizontally', - [ - { - commandName: 'flipViewportHorizontal', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Flip Horizontal' - ), - _createToolButton( - 'StackScroll', - 'tool-stack-scroll', - 'Stack Scroll', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'StackScroll', - }, - context: 'CORNERSTONE', - }, - ], - 'Stack Scroll' - ), - _createActionButton( - 'invert', - 'tool-invert', - 'Invert', - [ - { - commandName: 'invertViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Invert Colors' - ), - _createToolButton( - 'CalibrationLine', - 'tool-calibration', - 'Calibration', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'CalibrationLine', - }, - context: 'CORNERSTONE', - }, - ], - 'Calibration Line' - ), - ], - }, + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'FlipHorizontal', + icon: 'tool-flip-horizontal', + label: 'Flip Horizontally', + tooltip: 'Flip Horizontally', + commands: [ + { + commandName: 'flipViewportHorizontal', + context: 'CORNERSTONE', + }, + ], + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'StackScroll', + icon: 'tool-stack-scroll', + label: 'Stack Scroll', + tooltip: 'Stack Scroll', + commands: _createSetToolActiveCommands('StackScroll'), + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'Invert', + icon: 'tool-invert', + label: 'Invert Colors', + tooltip: 'Invert Colors', + commands: [ + { + commandName: 'invertViewport', + context: 'CORNERSTONE', + }, + ], + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'CalibrationLine', + icon: 'tool-calibration', + label: 'Calibration Line', + tooltip: 'Calibration Line', + commands: _createSetToolActiveCommands('CalibrationLine'), + evaluate: 'evaluate.cornerstoneTool', + }), + + ], }, +}, ]; export default toolbarButtons; diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index a6e9d0827..155fc7013 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -3,16 +3,12 @@ import toolbarButtons from './toolbarButtons'; import { id } from './id'; import initToolGroups from './initToolGroups'; import moreTools from './moreTools'; -import moreToolsMpr from './moreToolsMpr'; import i18n from 'i18next'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG']; -const DEFAULT_TOOL_GROUP_ID = 'default'; -const MPR_TOOL_GROUP_ID = 'mpr'; - const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', @@ -82,50 +78,8 @@ function modeFactory() { '@ohif/extension-test.customizationModule.custom-context-menu', ]); - let unsubscribe; - toolbarService.setDefaultTool({ - groupId: 'WindowLevel', - itemId: 'WindowLevel', - interactionType: 'tool', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - }); - - const activateTool = () => { - toolbarService.recordInteraction(toolbarService.getDefaultTool()); - - // We don't need to reset the active tool whenever a viewport is getting - // added to the toolGroup. - unsubscribe(); - }; - - // Since we only have one viewport for the basic cs3d mode and it has - // only one hanging protocol, we can just use the first viewport - ({ unsubscribe } = toolGroupService.subscribe( - toolGroupService.EVENTS.VIEWPORT_ADDED, - activateTool - )); - - toolbarService.init(extensionManager); - toolbarService.addButtons([...toolbarButtons, ...moreTools, ...moreToolsMpr]); - toolbarService.createButtonSection(DEFAULT_TOOL_GROUP_ID, [ - 'MeasurementTools', - 'Zoom', - 'WindowLevel', - 'Pan', - 'Capture', - 'Layout', - 'MPR', - 'MoreTools', - ]); - toolbarService.createButtonSection(MPR_TOOL_GROUP_ID, [ + toolbarService.addButtons([...toolbarButtons, ...moreTools]); + toolbarService.createButtonSection('primary', [ 'MeasurementTools', 'Zoom', 'WindowLevel', @@ -134,7 +88,7 @@ function modeFactory() { 'Layout', 'MPR', 'Crosshairs', - 'MoreToolsMpr', + 'MoreTools', ]); }, onModeExit: ({ servicesManager }) => { diff --git a/modes/basic-test-mode/src/moreTools.ts b/modes/basic-test-mode/src/moreTools.ts index 9b7726b80..e7da76369 100644 --- a/modes/basic-test-mode/src/moreTools.ts +++ b/modes/basic-test-mode/src/moreTools.ts @@ -1,228 +1,183 @@ import type { RunCommand } from '@ohif/core/types'; import { EVENTS } from '@cornerstonejs/core'; -import { ToolbarService } from '@ohif/core'; +import { ToolbarService, ViewportGridService } from '@ohif/core'; +import { setToolActiveToolbar } from './toolbarButtons'; +const { createButton } = ToolbarService; -const ReferenceLinesCommands: RunCommand = [ +const ReferenceLinesListeners: RunCommand = [ { commandName: 'setSourceViewportForReferenceLinesTool', context: 'CORNERSTONE', }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ReferenceLines', - }, - context: 'CORNERSTONE', - }, ]; const moreTools = [ { id: 'MoreTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { - isRadio: true, // ? groupId: 'MoreTools', - primary: ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - }, - ], - 'Reset' - ), + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: createButton({ + id: 'Reset', + icon: 'tool-reset', + tooltip: 'Reset View', + label: 'Reset', + commands: 'resetViewport', + evaluate: 'evaluate.action', + }), secondary: { icon: 'chevron-down', label: '', - isActive: true, tooltip: 'More Tools', }, items: [ - ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', + createButton({ + id: 'Reset', + icon: 'tool-reset', + label: 'Reset View', + tooltip: 'Reset View', + commands: 'resetViewport', + evaluate: 'evaluate.action', + }), + createButton({ + id: 'rotate-right', + icon: 'tool-rotate-right', + label: 'Rotate Right', + tooltip: 'Rotate +90', + commands: 'rotateViewportCW', + evaluate: 'evaluate.action', + }), + createButton({ + id: 'flipHorizontal', + icon: 'tool-flip-horizontal', + label: 'Flip Horizontal', + tooltip: 'Flip Horizontally', + commands: 'flipViewportHorizontal', + evaluate: 'evaluate.viewportProperties.toggle', + }), + createButton({ + id: 'ImageSliceSync', + icon: 'link', + label: 'Image Slice Sync', + tooltip: 'Enable position synchronization on stack viewports', + commands: { + commandName: 'toggleSynchronizer', + commandOptions: { + type: 'imageSlice', }, - ], - 'Reset' - ), - ToolbarService._createActionButton( - 'rotate-right', - 'tool-rotate-right', - 'Rotate Right', - [ - { - commandName: 'rotateViewportCW', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Rotate +90' - ), - ToolbarService._createActionButton( - 'flip-horizontal', - 'tool-flip-horizontal', - 'Flip Horizontally', - [ - { - commandName: 'flipViewportHorizontal', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Flip Horizontally' - ), - ToolbarService._createToggleButton( - 'ImageSliceSync', - 'link', - 'Image Slice Sync', - [ - { + }, + listeners: { + [EVENTS.STACK_VIEWPORT_NEW_STACK]: { commandName: 'toggleImageSliceSync', + commandOptions: { toggledState: true }, }, - ], - 'Enable position synchronization on stack viewports', - { - listeners: { - [EVENTS.STACK_VIEWPORT_NEW_STACK]: { - commandName: 'toggleImageSliceSync', - commandOptions: { toggledState: true }, - }, + }, + evaluate: 'evaluate.cornerstone.synchronizer', + }), + createButton({ + id: 'ReferenceLines', + icon: 'tool-referenceLines', + label: 'Reference Lines', + tooltip: 'Show Reference Lines', + commands: { + commandName: 'setToolEnabled', + commandOptions: { + toolName: 'ReferenceLines', + toggle: true, }, - } - ), - ToolbarService._createToggleButton( - 'ReferenceLines', - 'tool-referenceLines', // change this with the new icon - 'Reference Lines', - ReferenceLinesCommands, - 'Show Reference Lines', - { - listeners: { - [EVENTS.STACK_VIEWPORT_NEW_STACK]: ReferenceLinesCommands, - [EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesCommands, + }, + listeners: { + [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners, + [ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners, + }, + evaluate: 'evaluate.cornerstoneTool.toggle', + }), + createButton({ + id: 'ImageOverlay', + icon: 'toggle-dicom-overlay', + label: 'Image Overlay', + tooltip: 'Toggle Image Overlay', + commands: { + commandName: 'setToolEnabled', + commandOptions: { + toolName: 'ImageOverlayViewer', + toggle: true, }, - } - ), - ToolbarService._createToolButton( - 'StackScroll', - 'tool-stack-scroll', - 'Stack Scroll', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'StackScroll', - }, - context: 'CORNERSTONE', - }, - ], - 'Stack Scroll' - ), - ToolbarService._createActionButton( - 'invert', - 'tool-invert', - 'Invert', - [ - { - commandName: 'invertViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Invert Colors' - ), - ToolbarService._createToolButton( - 'Probe', - 'tool-probe', - 'Probe', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'DragProbe', - }, - context: 'CORNERSTONE', - }, - ], - 'Probe' - ), - ToolbarService._createToggleButton( - 'cine', - 'tool-cine', - 'Cine', - [ - { - commandName: 'toggleCine', - context: 'CORNERSTONE', - }, - ], - 'Cine' - ), - ToolbarService._createToolButton( - 'Angle', - 'tool-angle', - 'Angle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Angle', - }, - context: 'CORNERSTONE', - }, - ], - 'Angle' - ), - ToolbarService._createToolButton( - 'Magnify', - 'tool-magnify', - 'Magnify', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Magnify', - }, - context: 'CORNERSTONE', - }, - ], - 'Magnify' - ), - ToolbarService._createToolButton( - 'Rectangle', - 'tool-rectangle', - 'Rectangle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'RectangleROI', - }, - context: 'CORNERSTONE', - }, - ], - 'Rectangle' - ), - ToolbarService._createActionButton( - 'TagBrowser', - 'list-bullets', - 'Dicom Tag Browser', - [ - { - commandName: 'openDICOMTagViewer', - commandOptions: {}, - context: 'DEFAULT', - }, - ], - 'Dicom Tag Browser' - ), + }, + evaluate: 'evaluate.cornerstoneTool.toggle', + }), + createButton({ + id: 'StackScroll', + icon: 'tool-stack-scroll', + label: 'Stack Scroll', + tooltip: 'Stack Scroll', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'invert', + icon: 'tool-invert', + label: 'Invert', + tooltip: 'Invert Colors', + commands: 'invertViewport', + evaluate: 'evaluate.viewportProperties.toggle', + }), + createButton({ + id: 'Probe', + icon: 'tool-probe', + label: 'Probe', + tooltip: 'Probe', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'Cine', + icon: 'tool-cine', + label: 'Cine', + tooltip: 'Cine', + commands: 'toggleCine', + evaluate: 'evaluate.cine', + }), + createButton({ + id: 'Angle', + icon: 'tool-angle', + label: 'Angle', + tooltip: 'Angle', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'Magnify', + icon: 'tool-magnify', + label: 'Magnify', + tooltip: 'Magnify', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'RectangleROI', + icon: 'tool-rectangle', + label: 'Rectangle', + tooltip: 'Rectangle', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'CalibrationLine', + icon: 'tool-calibration', + label: 'Calibration', + tooltip: 'Calibration Line', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'TagBrowser', + icon: 'list-bullets', + label: 'Dicom Tag Browser', + tooltip: 'Dicom Tag Browser', + commands: 'openDICOMTagViewer', + }), ], }, }, diff --git a/modes/basic-test-mode/src/moreToolsMpr.ts b/modes/basic-test-mode/src/moreToolsMpr.ts deleted file mode 100644 index 0672170a3..000000000 --- a/modes/basic-test-mode/src/moreToolsMpr.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { ToolbarService } from '@ohif/core'; - -const moreToolsMpr = [ - { - id: 'MoreToolsMpr', - type: 'ohif.splitButton', - props: { - isRadio: true, // ? - groupId: 'MoreTools', - primary: ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - }, - ], - 'Reset' - ), - secondary: { - icon: 'chevron-down', - label: '', - isActive: true, - tooltip: 'More Tools', - }, - items: [ - ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - }, - ], - 'Reset' - ), - ToolbarService._createToolButton( - 'StackScroll', - 'tool-stack-scroll', - 'Stack Scroll', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'StackScroll', - }, - context: 'CORNERSTONE', - }, - ], - 'Stack Scroll' - ), - ToolbarService._createActionButton( - 'invert', - 'tool-invert', - 'Invert', - [ - { - commandName: 'invertViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Invert Colors' - ), - ToolbarService._createToolButton( - 'Probe', - 'tool-probe', - 'Probe', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'DragProbe', - }, - context: 'CORNERSTONE', - }, - ], - 'Probe' - ), - ToolbarService._createToggleButton( - 'cine', - 'tool-cine', - 'Cine', - [ - { - commandName: 'toggleCine', - context: 'CORNERSTONE', - }, - ], - 'Cine' - ), - ToolbarService._createToolButton( - 'Angle', - 'tool-angle', - 'Angle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Angle', - }, - context: 'CORNERSTONE', - }, - ], - 'Angle' - ), - ToolbarService._createToolButton( - 'Rectangle', - 'tool-rectangle', - 'Rectangle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'RectangleROI', - }, - context: 'CORNERSTONE', - }, - ], - 'Rectangle' - ), - ToolbarService._createActionButton( - 'TagBrowser', - 'list-bullets', - 'Dicom Tag Browser', - [ - { - commandName: 'openDICOMTagViewer', - commandOptions: {}, - context: 'DEFAULT', - }, - ], - 'Dicom Tag Browser' - ), - ], - }, - }, -]; - -export default moreToolsMpr; diff --git a/modes/basic-test-mode/src/toolbarButtons.ts b/modes/basic-test-mode/src/toolbarButtons.ts index 391aef110..34b300e22 100644 --- a/modes/basic-test-mode/src/toolbarButtons.ts +++ b/modes/basic-test-mode/src/toolbarButtons.ts @@ -1,14 +1,14 @@ // TODO: torn, can either bake this here; or have to create a whole new button type // Only ways that you can pass in a custom React component for render :l import { - // ExpandableToolbarButton, // ListMenu, WindowLevelMenuItem, } from '@ohif/ui'; -import { ToolbarService, defaults } from '@ohif/core'; +import { defaults, ToolbarService } from '@ohif/core'; import type { Button } from '@ohif/core/types'; const { windowLevelPresets } = defaults; +const { createButton } = ToolbarService; /** * @@ -21,7 +21,6 @@ function _createWwwcPreset(preset, title, subtitle) { id: preset.toString(), title, subtitle, - type: 'action', commands: [ { commandName: 'setWindowLevel', @@ -34,212 +33,106 @@ function _createWwwcPreset(preset, title, subtitle) { }; } +export const setToolActiveToolbar = { + commandName: 'setToolActiveToolbar', + commandOptions: { + toolGroupIds: ['default', 'mpr', 'SRToolGroup'], + }, +}; + const toolbarButtons: Button[] = [ - // Measurement { id: 'MeasurementTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'MeasurementTools', - isRadio: true, // ? - // Switch? - primary: ToolbarService._createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Length', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRLength', - toolGroupId: 'SRToolGroup', - }, - // we can use the setToolActive command for this from Cornerstone commandsModule - context: 'CORNERSTONE', - }, - ], - 'Length' - ), + // group evaluate to determine which item should move to the top + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', - label: '', - isActive: true, tooltip: 'More Measure Tools', }, items: [ - ToolbarService._createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Length', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRLength', - toolGroupId: 'SRToolGroup', - }, - // we can use the setToolActive command for this from Cornerstone commandsModule - context: 'CORNERSTONE', - }, - ], - 'Length Tool' - ), - ToolbarService._createToolButton( - 'Bidirectional', - 'tool-bidirectional', - 'Bidirectional', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Bidirectional', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRBidirectional', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Bidirectional Tool' - ), - ToolbarService._createToolButton( - 'ArrowAnnotate', - 'tool-annotate', - 'Annotation', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ArrowAnnotate', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRArrowAnnotate', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Arrow Annotate' - ), - ToolbarService._createToolButton( - 'EllipticalROI', - 'tool-elipse', - 'Ellipse', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'EllipticalROI', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SREllipticalROI', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Ellipse Tool' - ), - ToolbarService._createToolButton( - 'CircleROI', - 'tool-circle', - 'Circle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'CircleROI', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRCircleROI', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Circle Tool' - ), + createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'Bidirectional', + icon: 'tool-bidirectional', + label: 'Bidirectional', + tooltip: 'Bidirectional Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'ArrowAnnotate', + icon: 'tool-annotate', + label: 'Annotation', + tooltip: 'Arrow Annotate', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'EllipticalROI', + icon: 'tool-ellipse', + label: 'Ellipse', + tooltip: 'Ellipse ROI', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'CircleROI', + icon: 'tool-circle', + label: 'Circle', + tooltip: 'Circle Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), ], }, }, - // Zoom.. { id: 'Zoom', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-zoom', label: 'Zoom', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Zoom', - }, - context: 'CORNERSTONE', - }, - ], + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, // Window Level + Presets... { id: 'WindowLevel', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'WindowLevel', - primary: ToolbarService._createToolButton( - 'WindowLevel', - 'tool-window-level', - 'Window Level', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - 'Window Level' - ), + primary: createButton({ + id: 'WindowLevel', + icon: 'tool-window-level', + label: 'Window Level', + tooltip: 'Window Level', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', label: 'W/L Manual', - isActive: true, tooltip: 'W/L Presets', }, - isAction: true, // ? renderer: WindowLevelMenuItem, items: [ _createWwwcPreset(1, 'Soft tissue', '400 / 40'), @@ -253,137 +146,47 @@ const toolbarButtons: Button[] = [ // Pan... { id: 'Pan', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { type: 'tool', icon: 'tool-move', label: 'Pan', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Pan', - }, - context: 'CORNERSTONE', - }, - ], + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, { id: 'Capture', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { icon: 'tool-capture', label: 'Capture', - type: 'action', commands: [ { commandName: 'showDownloadViewportModal', - commandOptions: {}, - context: 'CORNERSTONE', }, ], + evaluate: 'evaluate.action', }, }, { id: 'Layout', - type: 'ohif.splitButton', + uiType: 'ohif.layoutSelector', props: { - groupId: 'LayoutTools', - isRadio: false, - primary: { - id: 'Layout', - type: 'action', - uiType: 'ohif.layoutSelector', - icon: 'tool-layout', - label: 'Grid Layout', - props: { - rows: 4, - columns: 4, - commands: [ - { - commandName: 'setLayout', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - }, - }, - secondary: { - icon: 'chevron-down', - label: '', - isActive: true, - tooltip: 'Hanging Protocols', - }, - items: [ + rows: 3, + columns: 3, + evaluate: 'evaluate.action', + commands: [ { - id: '2x2', - type: 'action', - label: '2x2', - commands: [ - { - commandName: 'setHangingProtocol', - commandOptions: { - protocolId: '@ohif/mnGrid', - stageId: '2x2', - }, - context: 'DEFAULT', - }, - ], - }, - { - id: '3x1', - type: 'action', - label: '3x1', - commands: [ - { - commandName: 'setHangingProtocol', - commandOptions: { - protocolId: '@ohif/mnGrid', - stageId: '3x1', - }, - context: 'DEFAULT', - }, - ], - }, - { - id: '2x1', - type: 'action', - label: '2x1', - commands: [ - { - commandName: 'setHangingProtocol', - commandOptions: { - protocolId: '@ohif/mnGrid', - stageId: '2x1', - }, - context: 'DEFAULT', - }, - ], - }, - { - id: '1x1', - type: 'action', - label: '1x1', - commands: [ - { - commandName: 'setHangingProtocol', - commandOptions: { - protocolId: '@ohif/mnGrid', - stageId: '1x1', - }, - context: 'DEFAULT', - }, - ], + commandName: 'setViewportGridLayout', }, ], }, }, { id: 'MPR', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { - type: 'toggle', icon: 'icon-mpr', label: 'MPR', commands: [ @@ -394,24 +197,23 @@ const toolbarButtons: Button[] = [ }, }, ], + evaluate: 'evaluate.mpr', }, }, { id: 'Crosshairs', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { type: 'tool', icon: 'tool-crosshair', label: 'Crosshairs', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolGroupId: 'mpr', - toolName: 'Crosshairs', - }, + commands: { + commandName: 'setToolActiveToolbar', + commandOptions: { + toolGroupIds: ['mpr'], }, - ], + }, + evaluate: 'evaluate.cornerstoneTool', }, }, ]; diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 571d6a5f6..87fe93251 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -2,17 +2,13 @@ import { hotkeys } from '@ohif/core'; import i18n from 'i18next'; import { id } from './id'; import initToolGroups from './initToolGroups'; -import moreTools from './moreTools'; -import moreToolsMpr from './moreToolsMpr'; import toolbarButtons from './toolbarButtons'; +import moreTools from './moreTools'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG', 'RTSTRUCT']; -const DEFAULT_TOOL_GROUP_ID = 'default'; -const MPR_TOOL_GROUP_ID = 'mpr'; - const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', @@ -75,63 +71,16 @@ function modeFactory({ modeConfiguration }) { * Lifecycle hooks */ onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { - const { - measurementService, - toolbarService, - toolGroupService, - panelService, - customizationService, - } = servicesManager.services; + const { measurementService, toolbarService, toolGroupService, customizationService } = + servicesManager.services; measurementService.clearMeasurements(); // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); - let unsubscribe; - toolbarService.setDefaultTool({ - groupId: 'WindowLevel', - itemId: 'WindowLevel', - interactionType: 'tool', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - }); - - const activateTool = () => { - toolbarService.recordInteraction(toolbarService.getDefaultTool()); - - // We don't need to reset the active tool whenever a viewport is getting - // added to the toolGroup. - unsubscribe(); - }; - - // Since we only have one viewport for the basic cs3d mode and it has - // only one hanging protocol, we can just use the first viewport - ({ unsubscribe } = toolGroupService.subscribe( - toolGroupService.EVENTS.VIEWPORT_ADDED, - activateTool - )); - - toolbarService.init(extensionManager); - toolbarService.addButtons([...toolbarButtons, ...moreTools, ...moreToolsMpr]); - toolbarService.createButtonSection(DEFAULT_TOOL_GROUP_ID, [ - 'MeasurementTools', - 'Zoom', - 'WindowLevel', - 'Pan', - 'Capture', - 'Layout', - 'MPR', - 'MoreTools', - ]); - toolbarService.createButtonSection(MPR_TOOL_GROUP_ID, [ + toolbarService.addButtons([...toolbarButtons, ...moreTools]); + toolbarService.createButtonSection('primary', [ 'MeasurementTools', 'Zoom', 'WindowLevel', @@ -140,7 +89,7 @@ function modeFactory({ modeConfiguration }) { 'Layout', 'MPR', 'Crosshairs', - 'MoreToolsMpr', + 'MoreTools', ]); customizationService.addModeCustomizations([ @@ -176,7 +125,6 @@ function modeFactory({ modeConfiguration }) { const { toolGroupService, syncGroupService, - toolbarService, segmentationService, cornerstoneViewportService, uiDialogService, diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index e7ea816db..bb9f0fcd3 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -54,15 +54,13 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage { toolName: toolNames.CalibrationLine }, ], // enabled - enabled: [{ toolName: toolNames.ImageOverlayViewer }], - // disabled - disabled: [{ toolName: toolNames.ReferenceLines }], + enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }], }; toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } -function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { +function initSRToolGroup(extensionManager, toolGroupService) { const SRUtilityModule = extensionManager.getModuleEntry( '@ohif/extension-cornerstone-dicom-sr.utilityModule.tools' ); @@ -186,6 +184,7 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { toolName: toolNames.Crosshairs, configuration: { viewportIndicators: false, + disableOnPassive: true, autoPan: { enabled: false, panSize: 10, @@ -230,7 +229,7 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) { function initToolGroups(extensionManager, toolGroupService, commandsManager) { initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default'); - initSRToolGroup(extensionManager, toolGroupService, commandsManager); + initSRToolGroup(extensionManager, toolGroupService); initMPRToolGroup(extensionManager, toolGroupService, commandsManager); initVolume3DToolGroup(extensionManager, toolGroupService); } diff --git a/modes/longitudinal/src/moreTools.ts b/modes/longitudinal/src/moreTools.ts index 5ed2fddc7..2f3d5c247 100644 --- a/modes/longitudinal/src/moreTools.ts +++ b/modes/longitudinal/src/moreTools.ts @@ -1,295 +1,183 @@ import type { RunCommand } from '@ohif/core/types'; import { EVENTS } from '@cornerstonejs/core'; -import { ToolbarService } from '@ohif/core'; +import { ToolbarService, ViewportGridService } from '@ohif/core'; +import { setToolActiveToolbar } from './toolbarButtons'; +const { createButton } = ToolbarService; -const ReferenceLinesCommands: RunCommand = [ +const ReferenceLinesListeners: RunCommand = [ { commandName: 'setSourceViewportForReferenceLinesTool', context: 'CORNERSTONE', }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ReferenceLines', - }, - context: 'CORNERSTONE', - }, ]; const moreTools = [ { id: 'MoreTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { - isRadio: true, // ? groupId: 'MoreTools', - primary: ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Reset' - ), + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: createButton({ + id: 'Reset', + icon: 'tool-reset', + tooltip: 'Reset View', + label: 'Reset', + commands: 'resetViewport', + evaluate: 'evaluate.action', + }), secondary: { icon: 'chevron-down', label: '', - isActive: true, tooltip: 'More Tools', }, items: [ - ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - commandOptions: {}, - context: 'CORNERSTONE', + createButton({ + id: 'Reset', + icon: 'tool-reset', + label: 'Reset View', + tooltip: 'Reset View', + commands: 'resetViewport', + evaluate: 'evaluate.action', + }), + createButton({ + id: 'rotate-right', + icon: 'tool-rotate-right', + label: 'Rotate Right', + tooltip: 'Rotate +90', + commands: 'rotateViewportCW', + evaluate: 'evaluate.action', + }), + createButton({ + id: 'flipHorizontal', + icon: 'tool-flip-horizontal', + label: 'Flip Horizontal', + tooltip: 'Flip Horizontally', + commands: 'flipViewportHorizontal', + evaluate: 'evaluate.viewportProperties.toggle', + }), + createButton({ + id: 'ImageSliceSync', + icon: 'link', + label: 'Image Slice Sync', + tooltip: 'Enable position synchronization on stack viewports', + commands: { + commandName: 'toggleSynchronizer', + commandOptions: { + type: 'imageSlice', }, - ], - 'Reset' - ), - ToolbarService._createActionButton( - 'rotate-right', - 'tool-rotate-right', - 'Rotate Right', - [ - { - commandName: 'rotateViewportCW', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Rotate +90' - ), - ToolbarService._createActionButton( - 'flip-horizontal', - 'tool-flip-horizontal', - 'Flip Horizontally', - [ - { - commandName: 'flipViewportHorizontal', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Flip Horizontal' - ), - ToolbarService._createToggleButton( - 'ImageSliceSync', - 'link', - 'Image Slice Sync', - [ - { + }, + listeners: { + [EVENTS.STACK_VIEWPORT_NEW_STACK]: { commandName: 'toggleImageSliceSync', + commandOptions: { toggledState: true }, }, - ], - 'Enable position synchronization on stack viewports', - { - listeners: { - [EVENTS.STACK_VIEWPORT_NEW_STACK]: { - commandName: 'toggleImageSliceSync', - commandOptions: { toggledState: true }, - }, + }, + evaluate: 'evaluate.cornerstone.synchronizer', + }), + createButton({ + id: 'ReferenceLines', + icon: 'tool-referenceLines', + label: 'Reference Lines', + tooltip: 'Show Reference Lines', + commands: { + commandName: 'setToolEnabled', + commandOptions: { + toolName: 'ReferenceLines', + toggle: true, // Toggle the tool on/off upon click }, - } - ), - ToolbarService._createToggleButton( - 'ReferenceLines', - 'tool-referenceLines', // change this with the new icon - 'Reference Lines', - ReferenceLinesCommands, - 'Show Reference Lines', - { - listeners: { - [EVENTS.STACK_VIEWPORT_NEW_STACK]: ReferenceLinesCommands, - [EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesCommands, + }, + listeners: { + [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners, + [ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners, + }, + evaluate: 'evaluate.cornerstoneTool.toggle', + }), + createButton({ + id: 'ImageOverlay', + icon: 'toggle-dicom-overlay', + label: 'Image Overlay', + tooltip: 'Toggle Image Overlay', + commands: { + commandName: 'setToolEnabled', + commandOptions: { + toolName: 'ImageOverlayViewer', + toggle: true, // Toggle the tool on/off upon click }, - } - ), - ToolbarService._createToggleButton( - 'ImageOverlayViewer', - 'toggle-dicom-overlay', - 'Image Overlay', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ImageOverlayViewer', - }, - context: 'CORNERSTONE', - }, - ], - 'Image Overlay', - { isActive: true } - ), - ToolbarService._createToolButton( - 'StackScroll', - 'tool-stack-scroll', - 'Stack Scroll', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'StackScroll', - }, - context: 'CORNERSTONE', - }, - ], - 'Stack Scroll' - ), - ToolbarService._createActionButton( - 'invert', - 'tool-invert', - 'Invert', - [ - { - commandName: 'invertViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Invert Colors' - ), - ToolbarService._createToolButton( - 'Probe', - 'tool-probe', - 'Probe', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'DragProbe', - }, - context: 'CORNERSTONE', - }, - ], - 'Probe' - ), - ToolbarService._createToggleButton( - 'cine', - 'tool-cine', - 'Cine', - [ - { - commandName: 'toggleCine', - context: 'CORNERSTONE', - }, - ], - 'Cine' - ), - ToolbarService._createToolButton( - 'Angle', - 'tool-angle', - 'Angle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Angle', - }, - context: 'CORNERSTONE', - }, - ], - 'Angle' - ), - - // Next two tools can be added once icons are added - // ToolbarService._createToolButton( - // 'Cobb Angle', - // 'tool-cobb-angle', - // 'Cobb Angle', - // [ - // { - // commandName: 'setToolActive', - // commandOptions: { - // toolName: 'CobbAngle', - // }, - // context: 'CORNERSTONE', - // }, - // ], - // 'Cobb Angle' - // ), - // ToolbarService._createToolButton( - // 'Planar Freehand ROI', - // 'tool-freehand', - // 'PlanarFreehandROI', - // [ - // { - // commandName: 'setToolActive', - // commandOptions: { - // toolName: 'PlanarFreehandROI', - // }, - // context: 'CORNERSTONE', - // }, - // ], - // 'Planar Freehand ROI' - // ), - ToolbarService._createToolButton( - 'Magnify', - 'tool-magnify', - 'Magnify', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Magnify', - }, - context: 'CORNERSTONE', - }, - ], - 'Magnify' - ), - ToolbarService._createToolButton( - 'Rectangle', - 'tool-rectangle', - 'Rectangle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'RectangleROI', - }, - context: 'CORNERSTONE', - }, - ], - 'Rectangle' - ), - ToolbarService._createToolButton( - 'CalibrationLine', - 'tool-calibration', - 'Calibration', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'CalibrationLine', - }, - context: 'CORNERSTONE', - }, - ], - 'Calibration Line' - ), - ToolbarService._createActionButton( - 'TagBrowser', - 'list-bullets', - 'Dicom Tag Browser', - [ - { - commandName: 'openDICOMTagViewer', - commandOptions: {}, - context: 'DEFAULT', - }, - ], - 'Dicom Tag Browser' - ), + }, + evaluate: 'evaluate.cornerstoneTool.toggle', + }), + createButton({ + id: 'StackScroll', + icon: 'tool-stack-scroll', + label: 'Stack Scroll', + tooltip: 'Stack Scroll', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'invert', + icon: 'tool-invert', + label: 'Invert', + tooltip: 'Invert Colors', + commands: 'invertViewport', + evaluate: 'evaluate.viewportProperties.toggle', + }), + createButton({ + id: 'Probe', + icon: 'tool-probe', + label: 'Probe', + tooltip: 'Probe', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'Cine', + icon: 'tool-cine', + label: 'Cine', + tooltip: 'Cine', + commands: 'toggleCine', + evaluate: 'evaluate.cine', + }), + createButton({ + id: 'Angle', + icon: 'tool-angle', + label: 'Angle', + tooltip: 'Angle', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'Magnify', + icon: 'tool-magnify', + label: 'Magnify', + tooltip: 'Magnify', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'RectangleROI', + icon: 'tool-rectangle', + label: 'Rectangle', + tooltip: 'Rectangle', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'CalibrationLine', + icon: 'tool-calibration', + label: 'Calibration', + tooltip: 'Calibration Line', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'TagBrowser', + icon: 'list-bullets', + label: 'Dicom Tag Browser', + tooltip: 'Dicom Tag Browser', + commands: 'openDICOMTagViewer', + }), ], }, }, diff --git a/modes/longitudinal/src/moreToolsMpr.ts b/modes/longitudinal/src/moreToolsMpr.ts deleted file mode 100644 index d34a42e47..000000000 --- a/modes/longitudinal/src/moreToolsMpr.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { RunCommand } from '@ohif/core/types'; -import { EVENTS } from '@cornerstonejs/core'; -import { ToolbarService } from '@ohif/core'; - -const ReferenceLinesCommands: RunCommand = [ - { - commandName: 'setSourceViewportForReferenceLinesTool', - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ReferenceLines', - }, - context: 'CORNERSTONE', - }, -]; - -const moreToolsMpr = [ - { - id: 'MoreToolsMpr', - type: 'ohif.splitButton', - props: { - isRadio: true, // ? - groupId: 'MoreTools', - primary: ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Reset' - ), - secondary: { - icon: 'chevron-down', - label: '', - isActive: true, - tooltip: 'More Tools', - }, - items: [ - ToolbarService._createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Reset' - ), - ToolbarService._createToggleButton( - 'ImageSliceSync', - 'link', - 'Image Slice Sync', - [ - { - commandName: 'toggleImageSliceSync', - }, - ], - 'Enable position synchronization on stack viewports', - { - listeners: { - [EVENTS.STACK_VIEWPORT_NEW_STACK]: { - commandName: 'toggleImageSliceSync', - commandOptions: { toggledState: true }, - }, - }, - } - ), - ToolbarService._createToggleButton( - 'ReferenceLines', - 'tool-referenceLines', // change this with the new icon - 'Reference Lines', - ReferenceLinesCommands, - 'Show Reference Lines', - { - listeners: { - [EVENTS.STACK_VIEWPORT_NEW_STACK]: ReferenceLinesCommands, - [EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesCommands, - }, - } - ), - ToolbarService._createToggleButton( - 'ImageOverlayViewer', - 'toggle-dicom-overlay', - 'Image Overlay', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ImageOverlayViewer', - }, - context: 'CORNERSTONE', - }, - ], - 'Image Overlay', - { isActive: true } - ), - ToolbarService._createToolButton( - 'StackScroll', - 'tool-stack-scroll', - 'Stack Scroll', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'StackScroll', - }, - context: 'CORNERSTONE', - }, - ], - 'Stack Scroll' - ), - ToolbarService._createActionButton( - 'invert', - 'tool-invert', - 'Invert', - [ - { - commandName: 'invertViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Invert Colors' - ), - ToolbarService._createToolButton( - 'Probe', - 'tool-probe', - 'Probe', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'DragProbe', - }, - context: 'CORNERSTONE', - }, - ], - 'Probe' - ), - ToolbarService._createToggleButton( - 'cine', - 'tool-cine', - 'Cine', - [ - { - commandName: 'toggleCine', - context: 'CORNERSTONE', - }, - ], - 'Cine' - ), - ToolbarService._createToolButton( - 'Angle', - 'tool-angle', - 'Angle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Angle', - }, - context: 'CORNERSTONE', - }, - ], - 'Angle' - ), - - // Next two tools can be added once icons are added - // ToolbarService._createToolButton( - // 'Cobb Angle', - // 'tool-cobb-angle', - // 'Cobb Angle', - // [ - // { - // commandName: 'setToolActive', - // commandOptions: { - // toolName: 'CobbAngle', - // }, - // context: 'CORNERSTONE', - // }, - // ], - // 'Cobb Angle' - // ), - // ToolbarService._createToolButton( - // 'Planar Freehand ROI', - // 'tool-freehand', - // 'PlanarFreehandROI', - // [ - // { - // commandName: 'setToolActive', - // commandOptions: { - // toolName: 'PlanarFreehandROI', - // }, - // context: 'CORNERSTONE', - // }, - // ], - // 'Planar Freehand ROI' - // ), - ToolbarService._createToolButton( - 'Rectangle', - 'tool-rectangle', - 'Rectangle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'RectangleROI', - }, - context: 'CORNERSTONE', - }, - ], - 'Rectangle' - ), - ToolbarService._createActionButton( - 'TagBrowser', - 'list-bullets', - 'Dicom Tag Browser', - [ - { - commandName: 'openDICOMTagViewer', - commandOptions: {}, - context: 'DEFAULT', - }, - ], - 'Dicom Tag Browser' - ), - ], - }, - }, -]; - -export default moreToolsMpr; diff --git a/modes/longitudinal/src/toolbarButtons.ts b/modes/longitudinal/src/toolbarButtons.ts index 2237edd2b..34b300e22 100644 --- a/modes/longitudinal/src/toolbarButtons.ts +++ b/modes/longitudinal/src/toolbarButtons.ts @@ -1,7 +1,6 @@ // TODO: torn, can either bake this here; or have to create a whole new button type // Only ways that you can pass in a custom React component for render :l import { - // ExpandableToolbarButton, // ListMenu, WindowLevelMenuItem, } from '@ohif/ui'; @@ -9,6 +8,7 @@ import { defaults, ToolbarService } from '@ohif/core'; import type { Button } from '@ohif/core/types'; const { windowLevelPresets } = defaults; +const { createButton } = ToolbarService; /** * @@ -21,7 +21,6 @@ function _createWwwcPreset(preset, title, subtitle) { id: preset.toString(), title, subtitle, - type: 'action', commands: [ { commandName: 'setWindowLevel', @@ -34,224 +33,106 @@ function _createWwwcPreset(preset, title, subtitle) { }; } -const toolGroupIds = ['default', 'mpr', 'SRToolGroup']; - -/** - * Creates an array of 'setToolActive' commands for the given toolName - one for - * each toolGroupId specified in toolGroupIds. - * @param {string} toolName - * @returns {Array} an array of 'setToolActive' commands - */ -function _createSetToolActiveCommands(toolName) { - const temp = toolGroupIds.map(toolGroupId => ({ - commandName: 'setToolActive', - commandOptions: { - toolGroupId, - toolName, - }, - context: 'CORNERSTONE', - })); - return temp; -} +export const setToolActiveToolbar = { + commandName: 'setToolActiveToolbar', + commandOptions: { + toolGroupIds: ['default', 'mpr', 'SRToolGroup'], + }, +}; const toolbarButtons: Button[] = [ - // Measurement { id: 'MeasurementTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'MeasurementTools', - isRadio: true, // ? - // Switch? - primary: ToolbarService._createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Length', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRLength', - toolGroupId: 'SRToolGroup', - }, - // we can use the setToolActive command for this from Cornerstone commandsModule - context: 'CORNERSTONE', - }, - ], - 'Length' - ), + // group evaluate to determine which item should move to the top + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', - label: '', - isActive: true, tooltip: 'More Measure Tools', }, items: [ - ToolbarService._createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Length', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRLength', - toolGroupId: 'SRToolGroup', - }, - // we can use the setToolActive command for this from Cornerstone commandsModule - context: 'CORNERSTONE', - }, - ], - 'Length Tool' - ), - ToolbarService._createToolButton( - 'Bidirectional', - 'tool-bidirectional', - 'Bidirectional', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Bidirectional', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRBidirectional', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Bidirectional Tool' - ), - ToolbarService._createToolButton( - 'ArrowAnnotate', - 'tool-annotate', - 'Annotation', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ArrowAnnotate', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRArrowAnnotate', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Arrow Annotate' - ), - ToolbarService._createToolButton( - 'EllipticalROI', - 'tool-elipse', - 'Ellipse', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'EllipticalROI', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SREllipticalROI', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Ellipse Tool' - ), - ToolbarService._createToolButton( - 'CircleROI', - 'tool-circle', - 'Circle', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'CircleROI', - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'SRCircleROI', - toolGroupId: 'SRToolGroup', - }, - context: 'CORNERSTONE', - }, - ], - 'Circle Tool' - ), + createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'Bidirectional', + icon: 'tool-bidirectional', + label: 'Bidirectional', + tooltip: 'Bidirectional Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'ArrowAnnotate', + icon: 'tool-annotate', + label: 'Annotation', + tooltip: 'Arrow Annotate', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'EllipticalROI', + icon: 'tool-ellipse', + label: 'Ellipse', + tooltip: 'Ellipse ROI', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + createButton({ + id: 'CircleROI', + icon: 'tool-circle', + label: 'Circle', + tooltip: 'Circle Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), ], }, }, - // Zoom.. { id: 'Zoom', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-zoom', label: 'Zoom', - commands: _createSetToolActiveCommands('Zoom'), + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, // Window Level + Presets... { id: 'WindowLevel', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'WindowLevel', - primary: ToolbarService._createToolButton( - 'WindowLevel', - 'tool-window-level', - 'Window Level', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - 'Window Level' - ), + primary: createButton({ + id: 'WindowLevel', + icon: 'tool-window-level', + label: 'Window Level', + tooltip: 'Window Level', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', label: 'W/L Manual', - isActive: true, tooltip: 'W/L Presets', }, - isAction: true, // ? renderer: WindowLevelMenuItem, items: [ _createWwwcPreset(1, 'Soft tissue', '400 / 40'), @@ -265,43 +146,47 @@ const toolbarButtons: Button[] = [ // Pan... { id: 'Pan', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { type: 'tool', icon: 'tool-move', label: 'Pan', - commands: _createSetToolActiveCommands('Pan'), + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, { id: 'Capture', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { icon: 'tool-capture', label: 'Capture', - type: 'action', commands: [ { commandName: 'showDownloadViewportModal', - commandOptions: {}, - context: 'CORNERSTONE', + }, + ], + evaluate: 'evaluate.action', + }, + }, + { + id: 'Layout', + uiType: 'ohif.layoutSelector', + props: { + rows: 3, + columns: 3, + evaluate: 'evaluate.action', + commands: [ + { + commandName: 'setViewportGridLayout', }, ], }, }, - { - id: 'Layout', - type: 'ohif.layoutSelector', - props: { - rows: 3, - columns: 3, - }, - }, { id: 'MPR', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { - type: 'toggle', icon: 'icon-mpr', label: 'MPR', commands: [ @@ -310,31 +195,27 @@ const toolbarButtons: Button[] = [ commandOptions: { protocolId: 'mpr', }, - context: 'DEFAULT', }, ], + evaluate: 'evaluate.mpr', }, }, { id: 'Crosshairs', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { type: 'tool', icon: 'tool-crosshair', label: 'Crosshairs', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Crosshairs', - toolGroupId: 'mpr', - }, - context: 'CORNERSTONE', + commands: { + commandName: 'setToolActiveToolbar', + commandOptions: { + toolGroupIds: ['mpr'], }, - ], + }, + evaluate: 'evaluate.cornerstoneTool', }, }, - // More... ]; export default toolbarButtons; diff --git a/modes/microscopy/src/index.tsx b/modes/microscopy/src/index.tsx index 557ec00d0..e7d30d396 100644 --- a/modes/microscopy/src/index.tsx +++ b/modes/microscopy/src/index.tsx @@ -50,7 +50,6 @@ function modeFactory({ modeConfiguration }) { onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { const { toolbarService } = servicesManager.services; - toolbarService.init(extensionManager); toolbarService.addButtons(toolbarButtons); toolbarService.createButtonSection('primary', ['MeasurementTools', 'dragPan']); }, diff --git a/modes/microscopy/src/toolbarButtons.js b/modes/microscopy/src/toolbarButtons.js index 40078c5de..9954089d7 100644 --- a/modes/microscopy/src/toolbarButtons.js +++ b/modes/microscopy/src/toolbarButtons.js @@ -1,182 +1,148 @@ -// TODO: torn, can either bake this here; or have to create a whole new button type -/** - * - * @param {*} type - 'tool' | 'action' | 'toggle' - * @param {*} id - * @param {*} icon - * @param {*} label - */ -function _createButton(type, id, icon, label, commands, tooltip) { - return { - id, - icon, - label, - type, - commands, - tooltip, - }; -} - -const _createActionButton = _createButton.bind(null, 'action'); -const _createToggleButton = _createButton.bind(null, 'toggle'); -const _createToolButton = _createButton.bind(null, 'tool'); +import { ToolbarService } from '@ohif/core'; const toolbarButtons = [ - // Measurement { id: 'MeasurementTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'MeasurementTools', - isRadio: true, // ? - // Switch? - primary: _createToolButton( - 'line', - 'tool-length', - 'Line', - [ + // group evaluate to determine which item should move to the top + evaluate: 'evaluate.group.promoteToPrimary', + primary: ToolbarService.createButton({ + id: 'line', + icon: 'tool-length', + label: 'Line', + tooltip: 'Line', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'line', - }, + commandOptions: { toolName: 'line' }, context: 'MICROSCOPY', }, ], - 'Line' - ), + evaluate: 'evaluate.microscopyTool', + }), secondary: { icon: 'chevron-down', - label: '', - isActive: true, tooltip: 'More Measure Tools', }, items: [ - _createToolButton( - 'line', - 'tool-length', - 'Line', - [ + ToolbarService.createButton({ + id: 'line', + icon: 'tool-length', + label: 'Line', + tooltip: 'Line', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'line', - }, + commandOptions: { toolName: 'line' }, context: 'MICROSCOPY', }, ], - 'Line Tool' - ), - _createToolButton( - 'point', - 'tool-point', - 'Point', - [ + evaluate: 'evaluate.microscopyTool', + }), + ToolbarService.createButton({ + id: 'point', + icon: 'tool-point', + label: 'Point', + tooltip: 'Point Tool', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'point', - }, + commandOptions: { toolName: 'point' }, context: 'MICROSCOPY', }, ], - 'Point Tool' - ), - _createToolButton( - 'polygon', - 'tool-polygon', - 'Polygon', - [ + evaluate: 'evaluate.microscopyTool', + }), + // Point Tool was previously defined + ToolbarService.createButton({ + id: 'polygon', + icon: 'tool-polygon', + label: 'Polygon', + tooltip: 'Polygon Tool', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'polygon', - }, + commandOptions: { toolName: 'polygon' }, context: 'MICROSCOPY', }, ], - 'Polygon Tool' - ), - _createToolButton( - 'circle', - 'tool-circle', - 'Circle', - [ + evaluate: 'evaluate.microscopyTool', + }), + ToolbarService.createButton({ + id: 'circle', + icon: 'tool-circle', + label: 'Circle', + tooltip: 'Circle Tool', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'circle', - }, + commandOptions: { toolName: 'circle' }, context: 'MICROSCOPY', }, ], - 'Circle Tool' - ), - _createToolButton( - 'box', - 'tool-rectangle', - 'Box', - [ + evaluate: 'evaluate.microscopyTool', + }), + ToolbarService.createButton({ + id: 'box', + icon: 'tool-rectangle', + label: 'Box', + tooltip: 'Box Tool', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'box', - }, + commandOptions: { toolName: 'box' }, context: 'MICROSCOPY', }, ], - 'Box Tool' - ), - _createToolButton( - 'freehandpolygon', - 'tool-freehand-polygon', - 'Freehand Polygon', - [ + evaluate: 'evaluate.microscopyTool', + }), + ToolbarService.createButton({ + id: 'freehandpolygon', + icon: 'tool-freehand-polygon', + label: 'Freehand Polygon', + tooltip: 'Freehand Polygon Tool', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'freehandpolygon', - }, + commandOptions: { toolName: 'freehandpolygon' }, context: 'MICROSCOPY', }, ], - 'Freehand Polygon Tool' - ), - _createToolButton( - 'freehandline', - 'tool-freehand-line', - 'Freehand Line', - [ + evaluate: 'evaluate.microscopyTool', + }), + ToolbarService.createButton({ + id: 'freehandline', + icon: 'tool-freehand-line', + label: 'Freehand Line', + tooltip: 'Freehand Line Tool', + commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'freehandline', - }, + commandOptions: { toolName: 'freehandline' }, context: 'MICROSCOPY', }, ], - 'Freehand Line Tool' - ), + evaluate: 'evaluate.microscopyTool', + }), ], }, }, - // Pan... { id: 'dragPan', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-move', label: 'Pan', commands: [ { commandName: 'setToolActive', - commandOptions: { - toolName: 'dragPan', - }, + commandOptions: { toolName: 'dragPan' }, context: 'MICROSCOPY', }, ], + evaluate: 'evaluate.microscopyTool', }, }, ]; diff --git a/modes/segmentation/src/index.tsx b/modes/segmentation/src/index.tsx index 57b46c603..e4db1b9e9 100644 --- a/modes/segmentation/src/index.tsx +++ b/modes/segmentation/src/index.tsx @@ -1,6 +1,7 @@ import { hotkeys } from '@ohif/core'; import { id } from './id'; import toolbarButtons from './toolbarButtons'; +import segmentationButtons from './segmentationButtons'; import initToolGroups from './initToolGroups'; const ohif = { @@ -57,47 +58,19 @@ function modeFactory({ modeConfiguration }) { // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); - let unsubscribe; - - const activateTool = () => { - toolbarService.recordInteraction({ - groupId: 'WindowLevel', - interactionType: 'tool', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - }); - - // We don't need to reset the active tool whenever a viewport is getting - // added to the toolGroup. - unsubscribe(); - }; - - // Since we only have one viewport for the basic cs3d mode and it has - // only one hanging protocol, we can just use the first viewport - ({ unsubscribe } = toolGroupService.subscribe( - toolGroupService.EVENTS.VIEWPORT_ADDED, - activateTool - )); - - toolbarService.init(extensionManager); toolbarService.addButtons(toolbarButtons); + toolbarService.addButtons(segmentationButtons); + toolbarService.createButtonSection('primary', [ - 'Zoom', - 'WindowLevel', 'Pan', 'Capture', 'Layout', 'MPR', 'Crosshairs', + 'Zoom', 'MoreTools', ]); + toolbarService.createButtonSection('segmentationToolbox', ['BrushTools', 'Shapes']); }, onModeExit: ({ servicesManager }) => { const { diff --git a/modes/segmentation/src/initToolGroups.ts b/modes/segmentation/src/initToolGroups.ts index e549f7a89..31895daf0 100644 --- a/modes/segmentation/src/initToolGroups.ts +++ b/modes/segmentation/src/initToolGroups.ts @@ -41,11 +41,7 @@ function createTools(utilityModule) { parentTool: 'Brush', configuration: { activeStrategy: 'THRESHOLD_INSIDE_CIRCLE', - strategySpecificConfiguration: { - THRESHOLD: { - threshold: [-500, 500], - }, - }, + dynamicRadius: 3, }, }, { @@ -53,11 +49,7 @@ function createTools(utilityModule) { parentTool: 'Brush', configuration: { activeStrategy: 'THRESHOLD_INSIDE_SPHERE', - strategySpecificConfiguration: { - THRESHOLD: { - threshold: [-500, 500], - }, - }, + dynamicRadius: 3, }, }, { toolName: toolNames.CircleScissors }, diff --git a/modes/segmentation/src/segmentationButtons.ts b/modes/segmentation/src/segmentationButtons.ts new file mode 100644 index 000000000..012988ea2 --- /dev/null +++ b/modes/segmentation/src/segmentationButtons.ts @@ -0,0 +1,195 @@ +import type { Button } from '@ohif/core/types'; + +function _createSetToolActiveCommands(toolName) { + return [ + { + commandName: 'setToolActive', + commandOptions: { + toolName, + }, + }, + ]; +} + +const toolbarButtons: Button[] = [ + { + id: 'BrushTools', + uiType: 'ohif.buttonGroup', + props: { + groupId: 'BrushTools', + items: [ + { + id: 'Brush', + icon: 'icon-tool-brush', + label: 'Brush', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { toolNames: ['CircularBrush', 'SphereBrush'] }, + }, + commands: _createSetToolActiveCommands('CircularBrush'), + options: [ + { + name: 'Radius (mm)', + id: 'brush-radius', + type: 'range', + min: 0.5, + max: 99.5, + step: 0.5, + value: 25, + commands: { + commandName: 'setBrushSize', + commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] }, + }, + }, + { + name: 'Shape', + type: 'radio', + id: 'brush-mode', + value: 'CircularBrush', + values: [ + { value: 'CircularBrush', label: 'Circle' }, + { value: 'SphereBrush', label: 'Sphere' }, + ], + commands: 'setToolActiveToolbar', + }, + ], + }, + { + id: 'Eraser', + icon: 'icon-tool-eraser', + label: 'Eraser', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { + toolNames: ['CircularEraser', 'SphereEraser'], + }, + }, + commands: _createSetToolActiveCommands('CircularEraser'), + options: [ + { + name: 'Radius (mm)', + id: 'eraser-radius', + type: 'range', + min: 0.5, + max: 99.5, + step: 0.5, + value: 25, + commands: { + commandName: 'setBrushSize', + commandOptions: { toolNames: ['CircularEraser', 'SphereEraser'] }, + }, + }, + { + name: 'Shape', + type: 'radio', + id: 'eraser-mode', + value: 'CircularEraser', + values: [ + { value: 'CircularEraser', label: 'Circle' }, + { value: 'SphereEraser', label: 'Sphere' }, + ], + commands: 'setToolActiveToolbar', + }, + ], + }, + { + id: 'Threshold', + icon: 'icon-tool-threshold', + label: 'Eraser', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'] }, + }, + commands: _createSetToolActiveCommands('ThresholdCircularBrush'), + options: [ + { + name: 'Radius (mm)', + id: 'threshold-radius', + type: 'range', + min: 0.5, + max: 99.5, + step: 0.5, + value: 25, + commands: { + commandName: 'setBrushSize', + commandOptions: { + toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'], + }, + }, + }, + { + name: 'Shape', + type: 'radio', + id: 'eraser-mode', + value: 'ThresholdCircularBrush', + values: [ + { value: 'ThresholdCircularBrush', label: 'Circle' }, + { value: 'ThresholdSphereBrush', label: 'Sphere' }, + ], + commands: 'setToolActiveToolbar', + }, + { + name: 'Threshold', + type: 'radio', + id: 'dynamic-mode', + value: 'ThresholdRange', + values: [ + { value: 'ThresholdDynamic', label: 'Dynamic' }, + { value: 'ThresholdRange', label: 'Range' }, + ], + commands: { + commandName: 'toggleThresholdRangeAndDynamic', + }, + }, + { + name: 'ThresholdRange', + type: 'double-range', + id: 'threshold-range', + min: -1000, + max: 1000, + step: 1, + values: [100, 600], + condition: ({ options }) => + options.find(option => option.id === 'dynamic-mode').value === 'ThresholdRange', + commands: { + commandName: 'setThresholdRange', + commandOptions: { + toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'], + }, + }, + }, + ], + }, + ], + }, + }, + { + id: 'Shapes', + uiType: 'ohif.radioGroup', + props: { + label: 'Shapes', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'] }, + }, + icon: 'icon-tool-shape', + commands: _createSetToolActiveCommands('CircleScissor'), + options: [ + { + name: 'Shape', + type: 'radio', + value: 'CircleScissor', + id: 'shape-mode', + values: [ + { value: 'CircleScissor', label: 'Circle' }, + { value: 'SphereScissor', label: 'Sphere' }, + { value: 'RectangleScissor', label: 'Rectangle' }, + ], + commands: 'setToolActiveToolbar', + }, + ], + }, + }, +]; + +export default toolbarButtons; diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index 9795beca7..bbff5e2d6 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -1,46 +1,14 @@ -import { - // ExpandableToolbarButton, - // ListMenu, - WindowLevelMenuItem, -} from '@ohif/ui'; -import { defaults } from '@ohif/core'; +import { defaults, ToolbarService } from '@ohif/core'; +import type { Button } from '@ohif/core/types'; +import { WindowLevelMenuItem } from '@ohif/ui'; const { windowLevelPresets } = defaults; -/** - * - * @param {*} type - 'tool' | 'action' | 'toggle' - * @param {*} id - * @param {*} icon - * @param {*} label - */ -function _createButton(type, id, icon, label, commands, tooltip, uiType) { - return { - id, - icon, - label, - type, - commands, - tooltip, - uiType, - }; -} -const _createActionButton = _createButton.bind(null, 'action'); -const _createToggleButton = _createButton.bind(null, 'toggle'); -const _createToolButton = _createButton.bind(null, 'tool'); - -/** - * - * @param {*} preset - preset number (from above import) - * @param {*} title - * @param {*} subtitle - */ function _createWwwcPreset(preset, title, subtitle) { return { id: preset.toString(), title, subtitle, - type: 'action', commands: [ { commandName: 'setWindowLevel', @@ -53,16 +21,8 @@ function _createWwwcPreset(preset, title, subtitle) { }; } -const toolGroupIds = ['default', 'mpr', 'SRToolGroup']; - -/** - * Creates an array of 'setToolActive' commands for the given toolName - one for - * each toolGroupId specified in toolGroupIds. - * @param {string} toolName - * @returns {Array} an array of 'setToolActive' commands - */ -function _createSetToolActiveCommands(toolName) { - const temp = toolGroupIds.map(toolGroupId => ({ +function _createSetToolActiveCommands(toolName, toolGroupIds = ['default', 'mpr', 'SRToolGroup']) { + return toolGroupIds.map(toolGroupId => ({ commandName: 'setToolActive', commandOptions: { toolGroupId, @@ -70,49 +30,36 @@ function _createSetToolActiveCommands(toolName) { }, context: 'CORNERSTONE', })); - return temp; } -const toolbarButtons = [ - // Zoom.. +const toolbarButtons: Button[] = [ { id: 'Zoom', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-zoom', label: 'Zoom', commands: _createSetToolActiveCommands('Zoom'), + evaluate: 'evaluate.cornerstoneTool', }, }, - // Window Level + Presets... { id: 'WindowLevel', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'WindowLevel', - primary: _createToolButton( - 'WindowLevel', - 'tool-window-level', - 'Window Level', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - 'Window Level' - ), + primary: ToolbarService.createButton({ + id: 'WindowLevel', + icon: 'tool-window-level', + label: 'Window Level', + tooltip: 'Window Level', + commands: _createSetToolActiveCommands('WindowLevel'), + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', - label: 'W/L Manual', - isActive: true, tooltip: 'W/L Presets', }, - isAction: true, // ? renderer: WindowLevelMenuItem, items: [ _createWwwcPreset(1, 'Soft tissue', '400 / 40'), @@ -123,46 +70,49 @@ const toolbarButtons = [ ], }, }, - // Pan... { id: 'Pan', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-move', label: 'Pan', commands: _createSetToolActiveCommands('Pan'), + evaluate: 'evaluate.cornerstoneTool', }, }, { id: 'Capture', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { icon: 'tool-capture', label: 'Capture', - type: 'action', commands: [ { commandName: 'showDownloadViewportModal', - commandOptions: {}, context: 'CORNERSTONE', }, ], + evaluate: 'evaluate.action', + }, + }, + { + id: 'Layout', + uiType: 'ohif.layoutSelector', + props: { + rows: 3, + columns: 3, + evaluate: 'evaluate.action', + commands: [ + { + commandName: 'setViewportGridLayout', + }, + ], }, }, - { - id: 'Layout', - type: 'ohif.layoutSelector', - props: { - rows: 3, - columns: 3, - }, - }, { id: 'MPR', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { - type: 'toggle', icon: 'icon-mpr', label: 'MPR', commands: [ @@ -174,180 +124,124 @@ const toolbarButtons = [ context: 'DEFAULT', }, ], + evaluate: 'evaluate.mpr', }, }, { id: 'Crosshairs', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-crosshair', label: 'Crosshairs', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Crosshairs', - toolGroupId: 'mpr', - }, - context: 'CORNERSTONE', - }, - ], + commands: _createSetToolActiveCommands('Crosshairs', ['mpr']), + evaluate: 'evaluate.cornerstoneTool', }, }, - // More... { id: 'MoreTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { - isRadio: true, // ? groupId: 'MoreTools', - primary: _createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: ToolbarService.createButton({ + id: 'Reset', + icon: 'tool-reset', + label: 'Reset View', + tooltip: 'Reset', + commands: [ { commandName: 'resetViewport', - commandOptions: {}, context: 'CORNERSTONE', }, ], - 'Reset' - ), + evaluate: 'evaluate.action', + }), secondary: { icon: 'chevron-down', - label: '', - isActive: true, tooltip: 'More Tools', }, items: [ - _createActionButton( - 'Reset', - 'tool-reset', - 'Reset View', - [ - { - commandName: 'resetViewport', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - 'Reset' - ), - _createActionButton( - 'rotate-right', - 'tool-rotate-right', - 'Rotate Right', - [ + ToolbarService.createButton({ + id: 'RotateRight', + icon: 'tool-rotate-right', + label: 'Rotate Right', + tooltip: 'Rotate +90', + commands: [ { commandName: 'rotateViewportCW', - commandOptions: {}, context: 'CORNERSTONE', }, ], - 'Rotate +90' - ), - _createActionButton( - 'flip-horizontal', - 'tool-flip-horizontal', - 'Flip Horizontally', - [ + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'FlipHorizontal', + icon: 'tool-flip-horizontal', + label: 'Flip Horizontally', + tooltip: 'Flip Horizontal', + commands: [ { commandName: 'flipViewportHorizontal', - commandOptions: {}, context: 'CORNERSTONE', }, ], - 'Flip Horizontal' - ), - _createToggleButton('ImageSliceSync', 'link', 'Stack Image Sync', [ - { - commandName: 'toggleImageSliceSync', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ]), - _createToggleButton( - 'ReferenceLines', - 'tool-referenceLines', // change this with the new icon - 'Reference Lines', - [ - { - commandName: 'toggleReferenceLines', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ] - ), - _createToolButton( - 'StackScroll', - 'tool-stack-scroll', - 'Stack Scroll', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'StackScroll', - }, - context: 'CORNERSTONE', - }, - ], - 'Stack Scroll' - ), - _createActionButton( - 'invert', - 'tool-invert', - 'Invert', - [ + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'StackScroll', + icon: 'tool-stack-scroll', + label: 'Stack Scroll', + tooltip: 'Stack Scroll', + commands: _createSetToolActiveCommands('StackScroll'), + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'Magnify', + icon: 'tool-magnify', + label: 'Magnify', + tooltip: 'Magnify', + commands: _createSetToolActiveCommands('Magnify'), + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'Invert', + icon: 'tool-invert', + label: 'Invert', + tooltip: 'Invert Colors', + commands: [ { commandName: 'invertViewport', - commandOptions: {}, context: 'CORNERSTONE', }, ], - 'Invert Colors' - ), - _createToggleButton( - 'cine', - 'tool-cine', - 'Cine', - [ + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'Cine', + icon: 'tool-cine', + label: 'Cine', + tooltip: 'Cine', + commands: [ { commandName: 'toggleCine', context: 'CORNERSTONE', }, ], - 'Cine' - ), - _createToolButton( - 'Magnify', - 'tool-magnify', - 'Magnify', - [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'Magnify', - }, - context: 'CORNERSTONE', - }, - ], - 'Magnify' - ), - _createActionButton( - 'TagBrowser', - 'list-bullets', - 'Dicom Tag Browser', - [ + evaluate: 'evaluate.action', + }), + ToolbarService.createButton({ + id: 'DicomTagBrowser', + icon: 'list-bullets', + label: 'Dicom Tag Browser', + tooltip: 'Dicom Tag Browser', + commands: [ { commandName: 'openDICOMTagViewer', - commandOptions: {}, context: 'DEFAULT', }, ], - 'Dicom Tag Browser' - ), + evaluate: 'evaluate.action', + }), ], }, }, diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index b21a37582..92ccb893e 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -56,39 +56,6 @@ function modeFactory({ modeConfiguration }) { // Init Default and SR ToolGroups initToolGroups(toolNames, Enums, toolGroupService, commandsManager); - const setWindowLevelActive = () => { - toolbarService.recordInteraction({ - groupId: 'WindowLevel', - interactionType: 'tool', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: toolNames.WindowLevel, - toolGroupId: toolGroupIds.CT, - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: toolNames.WindowLevel, - toolGroupId: toolGroupIds.PT, - }, - context: 'CORNERSTONE', - }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: toolNames.WindowLevel, - toolGroupId: toolGroupIds.Fusion, - }, - context: 'CORNERSTONE', - }, - ], - }); - }; - const { unsubscribe } = toolGroupService.subscribe( toolGroupService.EVENTS.VIEWPORT_ADDED, () => { @@ -110,13 +77,10 @@ function modeFactory({ modeConfiguration }) { toolGroupService, displaySetService ); - - setWindowLevelActive(); } ); unsubscriptions.push(unsubscribe); - toolbarService.init(extensionManager); toolbarService.addButtons(toolbarButtons); toolbarService.createButtonSection('primary', [ 'MeasurementTools', @@ -124,6 +88,7 @@ function modeFactory({ modeConfiguration }) { 'WindowLevel', 'Crosshairs', 'Pan', + 'SyncToggle', 'RectangleROIStartEndThreshold', 'fusionPTColormap', ]); diff --git a/modes/tmtv/src/initToolGroups.js b/modes/tmtv/src/initToolGroups.js index f66dbd0c4..6585de299 100644 --- a/modes/tmtv/src/initToolGroups.js +++ b/modes/tmtv/src/initToolGroups.js @@ -60,6 +60,7 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { toolName: toolNames.Crosshairs, configuration: { viewportIndicators: false, + disableOnPassive: true, autoPan: { enabled: false, panSize: 10, diff --git a/modes/tmtv/src/toolbarButtons.js b/modes/tmtv/src/toolbarButtons.js index 556e21728..35d3f3383 100644 --- a/modes/tmtv/src/toolbarButtons.js +++ b/modes/tmtv/src/toolbarButtons.js @@ -1,26 +1,8 @@ -// TODO: torn, can either bake this here; or have to create a whole new button type -// Only ways that you can pass in a custom React component for render :l +import { defaults, ToolbarService } from '@ohif/core'; import { WindowLevelMenuItem } from '@ohif/ui'; -import { defaults } from '@ohif/core'; import { toolGroupIds } from './initToolGroups'; + const { windowLevelPresets } = defaults; -/** - * - * @param {*} type - 'tool' | 'action' | 'toggle' - * @param {*} id - * @param {*} icon - * @param {*} label - */ -function _createButton(type, id, icon, label, commands, tooltip) { - return { - id, - icon, - label, - type, - commands, - tooltip, - }; -} function _createColormap(label, colormap) { return { @@ -38,23 +20,11 @@ function _createColormap(label, colormap) { ], }; } - -const _createActionButton = _createButton.bind(null, 'action'); -const _createToggleButton = _createButton.bind(null, 'toggle'); -const _createToolButton = _createButton.bind(null, 'tool'); - -/** - * - * @param {*} preset - preset number (from above import) - * @param {*} title - * @param {*} subtitle - */ function _createWwwcPreset(preset, title, subtitle) { return { id: preset.toString(), title, subtitle, - type: 'action', commands: [ { commandName: 'setWindowLevel', @@ -67,170 +37,103 @@ function _createWwwcPreset(preset, title, subtitle) { }; } -function _createCommands(commandName, toolName, toolGroupIds) { - return toolGroupIds.map(toolGroupId => ({ - /* It's a command that is being run when the button is clicked. */ - commandName, - commandOptions: { - toolName, - toolGroupId, - }, - context: 'CORNERSTONE', - })); -} +const setToolActiveToolbar = { + commandName: 'setToolActiveToolbar', + commandOptions: { + toolGroupIds: [toolGroupIds.CT, toolGroupIds.PT, toolGroupIds.Fusion], + }, +}; const toolbarButtons = [ - // Measurement { id: 'MeasurementTools', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'MeasurementTools', - isRadio: true, // ? - // Switch? - primary: _createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - ..._createCommands('setToolActive', 'Length', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], - 'Length' - ), + primary: ToolbarService.createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', - label: '', - isActive: true, tooltip: 'More Measure Tools', }, items: [ - _createToolButton( - 'Length', - 'tool-length', - 'Length', - [ - ..._createCommands('setToolActive', 'Length', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], - 'Length Tool' - ), - _createToolButton( - 'Bidirectional', - 'tool-bidirectional', - 'Bidirectional', - [ - ..._createCommands('setToolActive', 'Bidirectional', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], - 'Bidirectional Tool' - ), - _createToolButton( - 'ArrowAnnotate', - 'tool-annotate', - 'Annotation', - [ - ..._createCommands('setToolActive', 'ArrowAnnotate', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], - 'Arrow Annotate' - ), - _createToolButton( - 'EllipticalROI', - 'tool-elipse', - 'Ellipse', - [ - ..._createCommands('setToolActive', 'EllipticalROI', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], - 'Ellipse Tool' - ), + ToolbarService.createButton({ + id: 'Bidirectional', + icon: 'tool-bidirectional', + label: 'Bidirectional', + tooltip: 'Bidirectional Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'ArrowAnnotate', + icon: 'tool-annotate', + label: 'Arrow Annotate', + tooltip: 'Arrow Annotate Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'EllipticalROI', + icon: 'tool-ellipse', + label: 'Ellipse', + tooltip: 'Ellipse Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), ], }, }, - // Zoom.. { id: 'Zoom', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-zoom', label: 'Zoom', - commands: [ - ..._createCommands('setToolActive', 'Zoom', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, { id: 'MPR', - type: 'ohif.action', + uiType: 'ohif.radioGroup', props: { - type: 'toggle', icon: 'icon-mpr', label: 'MPR', commands: [ { commandName: 'toggleHangingProtocol', - commandOptions: { - protocolId: 'mpr', - }, + commandOptions: { protocolId: 'mpr' }, context: 'DEFAULT', }, ], + evaluate: 'evaluate.mpr', }, }, - // Window Level + Presets... + // Window Level + Presets { id: 'WindowLevel', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'WindowLevel', - primary: _createToolButton( - 'WindowLevel', - 'tool-window-level', - 'Window Level', - [ - ..._createCommands('setToolActive', 'WindowLevel', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], - 'Window Level' - ), + primary: ToolbarService.createButton({ + id: 'WindowLevel', + icon: 'tool-window-level', + label: 'Window Level', + tooltip: 'Window Level', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), secondary: { icon: 'chevron-down', - label: 'W/L Manual', - isActive: true, tooltip: 'W/L Presets', }, - isAction: true, // ? renderer: WindowLevelMenuItem, items: [ _createWwwcPreset(1, 'Soft tissue', '400 / 40'), @@ -241,86 +144,57 @@ const toolbarButtons = [ ], }, }, + // Crosshairs Button { id: 'Crosshairs', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-crosshair', label: 'Crosshairs', - commands: [ - ..._createCommands('setToolActive', 'Crosshairs', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, - // Pan... + // Pan Button { id: 'Pan', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-move', label: 'Pan', - commands: [ - ..._createCommands('setToolActive', 'Pan', [ - toolGroupIds.CT, - toolGroupIds.PT, - toolGroupIds.Fusion, - // toolGroupIds.MPR, - ]), - ], + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, + // Rectangle ROI Start End Threshold Button { id: 'RectangleROIStartEndThreshold', - type: 'ohif.radioGroup', + uiType: 'ohif.radioGroup', props: { - type: 'tool', icon: 'tool-create-threshold', label: 'Rectangle ROI Threshold', - commands: [ - ..._createCommands('setToolActive', 'RectangleROIStartEndThreshold', [toolGroupIds.PT]), - { - commandName: 'displayNotification', - commandOptions: { - title: 'RectangleROI Threshold Tip', - text: 'RectangleROI Threshold tool should be used on PT Axial Viewport', - type: 'info', - }, - }, - { - commandName: 'setViewportActive', - commandOptions: { - viewportId: 'ptAXIAL', - }, - }, - ], + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', }, }, + // Fusion PT Colormap Button { id: 'fusionPTColormap', - type: 'ohif.splitButton', + uiType: 'ohif.splitButton', props: { groupId: 'fusionPTColormap', - primary: _createToolButton( - 'fusionPTColormap', - 'tool-fusion-color', - 'Fusion PT Colormap', - [], - 'Fusion PT Colormap' - ), + primary: ToolbarService.createButton({ + id: 'fusionPTColormap', + icon: 'tool-fusion-color', + label: 'Fusion PT Colormap', + tooltip: 'Fusion PT Colormap', + commands: [], + evaluate: 'evaluate.action', + }), secondary: { icon: 'chevron-down', - label: 'PT Colormap', - isActive: true, tooltip: 'PET Image Colormap', }, - isAction: true, // ? items: [ _createColormap('HSV', 'hsv'), _createColormap('Hot Iron', 'hot_iron'), diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js index 00383180f..45a60fa52 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js @@ -72,7 +72,7 @@ describe('OHIF Cornerstone Toolbar', () => { // Assign an alias to the button element cy.get('@wwwcBtnPrimary').as('wwwcButton'); cy.get('@wwwcButton').click(); - cy.get('@wwwcButton').should('have.class', 'active'); + cy.get('@wwwcButton').should('have.class', 'bg-primary-light'); //drags the mouse inside the viewport to be able to interact with series cy.get('@viewport') @@ -97,7 +97,7 @@ describe('OHIF Cornerstone Toolbar', () => { cy.get('@panButton').click(); // Assert that the button has the 'active' class - cy.get('@panButton').should('have.class', 'active'); + cy.get('@panButton').should('have.class', 'bg-primary-light'); // Trigger the pan actions on the viewport cy.get('@viewport') @@ -418,44 +418,44 @@ describe('OHIF Cornerstone Toolbar', () => { cy.get('@moreBtnSecondary').click(); //Click on Flip button - cy.get('[data-cy="flip-horizontal"]').click(); + cy.get('[data-cy="flipHorizontal"]').click(); cy.waitDicomImage(); cy.get('@viewportInfoMidLeft').should('contains.text', 'L'); cy.get('@viewportInfoMidTop').should('contains.text', 'A'); }); - it('checks if stack sync is preserved on new display set and uses FOR', () => { - // Active stack image sync and reference lines - cy.get('[data-cy="MoreTools-split-button-secondary"]').click(); - cy.get('[data-cy="ImageSliceSync"]').click(); - // Add reference lines as that sometimes throws an exception - cy.get('[data-cy="MoreTools-split-button-secondary"]').click(); - cy.get('[data-cy="ReferenceLines"]').click(); + // it('checks if stack sync is preserved on new display set and uses FOR', () => { + // // Active stack image sync and reference lines + // cy.get('[data-cy="MoreTools-split-button-secondary"]').click(); + // cy.get('[data-cy="ImageSliceSync"]').click(); + // // Add reference lines as that sometimes throws an exception + // cy.get('[data-cy="MoreTools-split-button-secondary"]').click(); + // cy.get('[data-cy="ReferenceLines"]').click(); - cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick(); - cy.get('body').type('{downarrow}{downarrow}'); + // cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick(); + // cy.get('body').type('{downarrow}{downarrow}'); - // Change the layout and double load the first - cy.setLayout(2, 1); - cy.get('body').type('{rightarrow}'); - cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick(); - cy.waitDicomImage(); + // // Change the layout and double load the first + // cy.setLayout(2, 1); + // cy.get('body').type('{rightarrow}'); + // cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick(); + // cy.waitDicomImage(); - // Now navigate down once and check that the left hand pane navigated - cy.get('body').focus().type('{downarrow}'); + // // Now navigate down once and check that the left hand pane navigated + // cy.get('body').focus().type('{downarrow}'); - // The following lines assist in troubleshooting when/if this test were to fail. - cy.get('[data-cy="viewport-pane"]') - .eq(0) - .find('[data-cy="viewport-overlay-top-right"]') - .should('contains.text', 'I:2 (2/20)'); - cy.get('[data-cy="viewport-pane"]') - .eq(1) - .find('[data-cy="viewport-overlay-top-right"]') - .should('contains.text', 'I:2 (2/20)'); + // // The following lines assist in troubleshooting when/if this test were to fail. + // cy.get('[data-cy="viewport-pane"]') + // .eq(0) + // .find('[data-cy="viewport-overlay-top-right"]') + // .should('contains.text', 'I:2 (2/20)'); + // cy.get('[data-cy="viewport-pane"]') + // .eq(1) + // .find('[data-cy="viewport-overlay-top-right"]') + // .should('contains.text', 'I:2 (2/20)'); - cy.get('body').type('{leftarrow}'); - cy.setLayout(1, 1); - cy.get('@viewportInfoTopRight').should('contains.text', 'I:2 (2/20)'); - }); + // cy.get('body').type('{leftarrow}'); + // cy.setLayout(1, 1); + // cy.get('@viewportInfoTopRight').should('contains.text', 'I:2 (2/20)'); + // }); }); diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js index 420cdd822..f23b2186e 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js @@ -18,7 +18,7 @@ describe('OHIF General Viewer', function () { cy.get('@zoomBtn') .click() .then($zoomBtn => { - cy.wrap($zoomBtn).should('have.class', 'active'); + cy.wrap($zoomBtn).should('have.class', 'bg-primary-light'); }); const zoomLevelInitial = cy.get('@viewportInfoTopLeft').then($viewportInfo => { diff --git a/platform/app/cypress/integration/volume/MPR.spec.js b/platform/app/cypress/integration/volume/MPR.spec.js index 56a2aa869..0d15e75e4 100644 --- a/platform/app/cypress/integration/volume/MPR.spec.js +++ b/platform/app/cypress/integration/volume/MPR.spec.js @@ -7,8 +7,7 @@ describe('OHIF MPR', () => { }); it('should not go MPR for non reconstructible displaySets', () => { - cy.get('[data-cy="MPR"]').click(); - cy.get('.cornerstone-canvas').should('have.length', 1); + cy.get('[data-cy="MPR"]').should('have.class', 'ohif-disabled'); }); it('should go MPR for reconstructible displaySets and come back', () => { @@ -88,7 +87,6 @@ describe('OHIF MPR', () => { }); it('should correctly render Crosshairs for MPR', () => { - cy.get('[data-cy="Crosshairs"]').should('not.exist'); cy.get(':nth-child(3) > [data-cy="study-browser-thumbnail"]').dblclick(); cy.get('[data-cy="MPR"]').click(); cy.get('[data-cy="Crosshairs"]').click(); @@ -125,13 +123,7 @@ describe('OHIF MPR', () => { cy.get('[data-cy="MPR"]').click(); cy.get('[data-cy="Crosshairs"]').click(); - // wait for the crosshairs tool to be active - cy.get('[data-cy="Crosshairs"].active'); - // Click the crosshairs button to deactivate it. cy.get('[data-cy="Crosshairs"]').click(); - - // wait for the window level button to be active - cy.get('[data-cy="WindowLevel-split-button-primary"].active'); }); }); diff --git a/platform/app/cypress/support/commands.js b/platform/app/cypress/support/commands.js index a84997598..51f17643b 100644 --- a/platform/app/cypress/support/commands.js +++ b/platform/app/cypress/support/commands.js @@ -273,7 +273,7 @@ Cypress.Commands.add( } }); - cy.get('@lengthButton').should('have.class', 'active'); + cy.get('@lengthButton').should('have.class', 'bg-primary-light'); cy.get('@viewport').then($viewport => { const [x1, y1] = firstClick; diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index 46a50c939..3a2fdb2ea 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -10,6 +10,9 @@ window.config = { strictZSpacingForVolumeViewport: true, // filterQueryParam: false, defaultDataSourceName: 'e2e', + investigationalUseDialog: { + option: 'never', + }, dataSources: [ { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index cadc05e2e..23b18b2ed 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -16,6 +16,7 @@ import { ViewportGridProvider, CineProvider, UserAuthenticationProvider, + ToolboxProvider, } from '@ohif/ui'; // Viewer Project // TODO: Should this influence study list? @@ -69,6 +70,7 @@ function App({ config, defaultExtensions, defaultModes }) { [UserAuthenticationProvider, { service: userAuthenticationService }], [I18nextProvider, { i18n }], [ThemeWrapper], + [ToolboxProvider], [ViewportGridProvider, { service: viewportGridService }], [ViewportDialogProvider, { service: uiViewportDialogService }], [CineProvider, { service: cineService }], diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index 168d05a04..0d93ae639 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -49,6 +49,8 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { appConfig, }); + servicesManager.setExtensionManager(extensionManager); + servicesManager.registerServices([ UINotificationService.REGISTRATION, UIModalService.REGISTRATION, diff --git a/platform/app/src/components/ViewportGrid.tsx b/platform/app/src/components/ViewportGrid.tsx index 8bb0f3f7c..850344642 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback, useRef } from 'react'; +import React, { useEffect, useCallback, useRef, useState } from 'react'; import ReactResizeDetector from 'react-resize-detector'; import PropTypes from 'prop-types'; import { ServicesManager, Types, MeasurementService } from '@ohif/core'; @@ -15,12 +15,15 @@ function ViewerViewportGrid(props) { const { layout, activeViewportId, viewports } = viewportGrid; const { numCols, numRows } = layout; const elementRef = useRef(null); + const layoutHash = useRef(null); // TODO -> Need some way of selecting which displaySets hit the viewports. const { displaySetService, measurementService, hangingProtocolService, uiNotificationService } = ( servicesManager as ServicesManager ).services; + const generateLayoutHash = () => `${numCols}-${numRows}`; + /** * This callback runs after the viewports structure has changed in any way. * On initial display, that means if it has changed by applying a HangingProtocol, @@ -135,6 +138,16 @@ function ViewerViewportGrid(props) { }; }, []); + // Check viewport readiness in useEffect + useEffect(() => { + const allReady = viewportGridService.getGridViewportsReady(); + const sameLayoutHash = layoutHash.current === generateLayoutHash(); + if (allReady && !sameLayoutHash) { + layoutHash.current = generateLayoutHash(); + viewportGridService.publishViewportsReady(); + } + }, [viewportGridService, generateLayoutHash]); + useEffect(() => { const { unsubscribe } = measurementService.subscribe( MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT, @@ -327,6 +340,9 @@ function ViewerViewportGrid(props) { viewportOptions={viewportOptions} displaySetOptions={displaySetOptions} needsRerendering={displaySetsNeedsRerendering} + onReady={() => { + viewportGridService.setViewportIsReady(viewportId, true); + }} />
@@ -360,7 +376,6 @@ function ViewerViewportGrid(props) { }} targetRef={elementRef.current} /> - {/* {ViewportPanes} */} {getViewportPanes()}
diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index 0b2b65bab..6c7fccf50 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -1,141 +1,18 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useContext, createContext } from 'react'; import { useParams, useLocation, useNavigate } from 'react-router'; import PropTypes from 'prop-types'; -// TODO: DicomMetadataStore should be injected? -import { DicomMetadataStore, ServicesManager, utils, log, Enums } from '@ohif/core'; +import { ServicesManager, utils } from '@ohif/core'; import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui'; import { useSearchParams } from '@hooks'; import { useAppConfig } from '@state'; import ViewportGrid from '@components/ViewportGrid'; import Compose from './Compose'; -import getStudies from './studiesList'; import { history } from '../../utils/history'; import loadModules from '../../pluginImports'; -import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed'; +import { defaultRouteInit } from './defaultRouteInit'; +import { updateAuthServiceAndCleanUrl } from './updateAuthServiceAndCleanUrl'; -const { getSplitParam, sortingCriteria } = utils; - -/** - * Initialize the route. - * - * @param props.servicesManager to read services from - * @param props.studyInstanceUIDs for a list of studies to read - * @param props.dataSource to read the data from - * @param props.filters filters from query params to read the data from - * @returns array of subscriptions to cancel - */ -function defaultRouteInit( - { servicesManager, studyInstanceUIDs, dataSource, filters, appConfig }, - hangingProtocolId -) { - const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } = - servicesManager.services; - /** - * Function to apply the hanging protocol when the minimum number of display sets were - * received or all display sets retrieval were completed - * @returns - */ - function applyHangingProtocol() { - const displaySets = displaySetService.getActiveDisplaySets(); - - if (!displaySets || !displaySets.length) { - return; - } - - // Gets the studies list to use - const studies = getStudies(studyInstanceUIDs, displaySets); - - // study being displayed, and is thus the "active" study. - const activeStudy = studies[0]; - - // run the hanging protocol matching on the displaySets with the predefined - // hanging protocol in the mode configuration - hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId); - } - - const unsubscriptions = []; - const issuedWarningSeries = []; - const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe( - DicomMetadataStore.EVENTS.INSTANCES_ADDED, - function ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) { - const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID); - - // checks if the series filter was used, if it exists - const seriesInstanceUIDs = filters?.seriesInstanceUID; - if ( - seriesInstanceUIDs?.length && - !isSeriesFilterUsed(seriesMetadata.instances, filters) && - !issuedWarningSeries.includes(seriesInstanceUIDs[0]) - ) { - // stores the series instance filter so it shows only once the warning - issuedWarningSeries.push(seriesInstanceUIDs[0]); - uiNotificationService.show({ - title: 'Series filter', - message: `Each of the series in filter: ${seriesInstanceUIDs} are not part of the current study. The entire study is being displayed`, - type: 'error', - duration: 7000, - }); - } - - displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient); - } - ); - - unsubscriptions.push(instanceAddedUnsubscribe); - - log.time(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS); - log.time(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE); - - const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID => - dataSource.retrieve.series.metadata({ - StudyInstanceUID, - filters, - returnPromises: true, - sortCriteria: - customizationService.get('sortingCriteria') || - sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria, - }) - ); - - // log the error if this fails, otherwise it's so difficult to tell what went wrong... - allRetrieves.forEach(retrieve => { - retrieve.catch(error => { - console.error(error); - }); - }); - - Promise.allSettled(allRetrieves).then(promises => { - log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS); - log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); - log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); - - const allPromises = []; - const remainingPromises = []; - - function startRemainingPromises(remainingPromises) { - remainingPromises.forEach(p => p.forEach(p => p.start())); - } - - promises.forEach(promise => { - const retrieveSeriesMetadataPromise = promise.value; - if (Array.isArray(retrieveSeriesMetadataPromise)) { - const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun( - hangingProtocolId, - retrieveSeriesMetadataPromise - ); - const requiredSeriesPromises = requiredSeries.map(promise => promise.start()); - allPromises.push(Promise.allSettled(requiredSeriesPromises)); - remainingPromises.push(remaining); - } - }); - - Promise.allSettled(allPromises).then(applyHangingProtocol); - startRemainingPromises(remainingPromises); - applyHangingProtocol(); - }); - - return unsubscriptions; -} +const { getSplitParam } = utils; export default function ModeRoute({ mode, @@ -166,7 +43,7 @@ export default function ModeRoute({ // The URL's query search parameters where the keys are all lower case. const lowerCaseSearchParams = useSearchParams({ lowerCaseKeys: true }); - const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(); + const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(null); const [refresh, setRefresh] = useState(false); const [ExtensionDependenciesLoaded, setExtensionDependenciesLoaded] = useState(false); @@ -193,25 +70,7 @@ export default function ModeRoute({ const token = lowerCaseSearchParams.get('token'); if (token) { - // if a token is passed in, set the userAuthenticationService to use it - // for the Authorization header for all requests - userAuthenticationService.setServiceImplementation({ - getAuthorizationHeader: () => ({ - Authorization: 'Bearer ' + token, - }), - }); - - // Create a URL object with the current location - const urlObj = new URL(window.location.origin + window.location.pathname + location.search); - - // Remove the token from the URL object - urlObj.searchParams.delete('token'); - const cleanUrl = urlObj.toString(); - - // Update the browser's history without the token - if (window.history && window.history.replaceState) { - window.history.replaceState(null, '', cleanUrl); - } + updateAuthServiceAndCleanUrl(token, location, userAuthenticationService); } // Preserve the old array interface for hotkeys @@ -228,32 +87,6 @@ export default function ModeRoute({ // Only handling one route per mode for now const route = mode.routes[0]; - // For each extension, look up their context modules - // TODO: move to extension manager. - let contextModules = []; - - Object.keys(extensions).forEach(extensionId => { - const allRegisteredModuleIds = Object.keys(extensionManager.modulesMap); - const moduleIds = allRegisteredModuleIds.filter(id => - id.includes(`${extensionId}.contextModule.`) - ); - - if (!moduleIds || !moduleIds.length) { - return; - } - - const modules = moduleIds.map(extensionManager.getModuleEntry); - contextModules = contextModules.concat(modules); - }); - - const contextModuleProviders = contextModules.map(a => a.provider); - const CombinedContextProvider = ({ children }) => - Compose({ components: contextModuleProviders, children }); - - function ViewportGridWithDataSource(props) { - return ViewportGrid({ ...props, dataSource }); - } - useEffect(() => { const loadExtensions = async () => { const loadedExtensions = await loadModules(Object.keys(extensions)); @@ -298,7 +131,7 @@ export default function ModeRoute({ }, [location, ExtensionDependenciesLoaded]); useEffect(() => { - if (!ExtensionDependenciesLoaded) { + if (!ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) { return; } @@ -313,7 +146,7 @@ export default function ModeRoute({ setRefresh(!refresh); } }; - if (studyInstanceUIDs?.length && studyInstanceUIDs[0] !== undefined) { + if (Array.isArray(studyInstanceUIDs) && studyInstanceUIDs[0]) { retrieveLayoutData(); } return () => { @@ -322,7 +155,7 @@ export default function ModeRoute({ }, [studyInstanceUIDs, ExtensionDependenciesLoaded]); useEffect(() => { - if (!hotkeys || !ExtensionDependenciesLoaded) { + if (!hotkeys || !ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) { return; } @@ -339,10 +172,10 @@ export default function ModeRoute({ return () => { hotkeysManager.destroy(); }; - }, [ExtensionDependenciesLoaded]); + }, [ExtensionDependenciesLoaded, hotkeys, studyInstanceUIDs]); useEffect(() => { - if (!layoutTemplateData.current || !ExtensionDependenciesLoaded) { + if (!layoutTemplateData.current || !ExtensionDependenciesLoaded || !studyInstanceUIDs?.length) { return; } @@ -477,7 +310,21 @@ export default function ModeRoute({ refresh, ]); - const renderLayoutData = props => { + if (!studyInstanceUIDs || !layoutTemplateData.current || !ExtensionDependenciesLoaded) { + return null; + } + + const ViewportGridWithDataSource = props => { + return ViewportGrid({ ...props, dataSource }); + }; + + const CombinedExtensionsContextProvider = createCombinedContextProvider( + extensionManager, + servicesManager, + commandsManager + ); + + const getLayoutComponent = props => { const layoutTemplateModuleEntry = extensionManager.getModuleEntry( layoutTemplateData.current.id ); @@ -486,27 +333,51 @@ export default function ModeRoute({ return ; }; + const LayoutComponent = getLayoutComponent({ + ...layoutTemplateData.current.props, + ViewportGridComp: ViewportGridWithDataSource, + }); + return ( - - - - {layoutTemplateData.current && - studyInstanceUIDs?.[0] !== undefined && - ExtensionDependenciesLoaded && - renderLayoutData({ - ...layoutTemplateData.current.props, - ViewportGridComp: ViewportGridWithDataSource, - })} - - + + {CombinedExtensionsContextProvider ? ( + + {LayoutComponent} + + ) : ( + {LayoutComponent} + )} ); } +/** + * Creates a combined context provider using the context modules from the extension manager. + * @param {object} extensionManager - The extension manager instance. + * @param {object} servicesManager - The services manager instance. + * @param {object} commandsManager - The commands manager instance. + * @returns {React.Component} - A React component that provides combined contexts to its children. + */ +function createCombinedContextProvider(extensionManager, servicesManager, commandsManager) { + const extensionsContextModules = extensionManager.getModulesByType( + extensionManager.constructor.MODULE_TYPES.CONTEXT + ); + + if (!extensionsContextModules?.length) { + return; + } + + const contextModuleProviders = extensionsContextModules.flatMap(({ module }) => { + return module.map(aContextModule => { + return aContextModule.provider; + }); + }); + + return ({ children }) => { + return Compose({ components: contextModuleProviders, children }); + }; +} + ModeRoute.propTypes = { mode: PropTypes.object.isRequired, dataSourceName: PropTypes.string, diff --git a/platform/app/src/routes/Mode/defaultRouteInit.ts b/platform/app/src/routes/Mode/defaultRouteInit.ts new file mode 100644 index 000000000..3f8aafaa7 --- /dev/null +++ b/platform/app/src/routes/Mode/defaultRouteInit.ts @@ -0,0 +1,129 @@ +import getStudies from './studiesList'; +import { DicomMetadataStore, log } from '@ohif/core'; +import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed'; + +import { utils, Enums } from '@ohif/core'; + +const { sortingCriteria } = utils; + +/** + * Initialize the route. + * + * @param props.servicesManager to read services from + * @param props.studyInstanceUIDs for a list of studies to read + * @param props.dataSource to read the data from + * @param props.filters filters from query params to read the data from + * @returns array of subscriptions to cancel + */ +export function defaultRouteInit( + { servicesManager, studyInstanceUIDs, dataSource, filters, appConfig }, + hangingProtocolId +) { + const { displaySetService, hangingProtocolService, uiNotificationService, customizationService } = + servicesManager.services; + /** + * Function to apply the hanging protocol when the minimum number of display sets were + * received or all display sets retrieval were completed + * @returns + */ + function applyHangingProtocol() { + const displaySets = displaySetService.getActiveDisplaySets(); + + if (!displaySets || !displaySets.length) { + return; + } + + // Gets the studies list to use + const studies = getStudies(studyInstanceUIDs, displaySets); + + // study being displayed, and is thus the "active" study. + const activeStudy = studies[0]; + + // run the hanging protocol matching on the displaySets with the predefined + // hanging protocol in the mode configuration + hangingProtocolService.run({ studies, activeStudy, displaySets }, hangingProtocolId); + } + + const unsubscriptions = []; + const issuedWarningSeries = []; + const { unsubscribe: instanceAddedUnsubscribe } = DicomMetadataStore.subscribe( + DicomMetadataStore.EVENTS.INSTANCES_ADDED, + function ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) { + const seriesMetadata = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID); + + // checks if the series filter was used, if it exists + const seriesInstanceUIDs = filters?.seriesInstanceUID; + if ( + seriesInstanceUIDs?.length && + !isSeriesFilterUsed(seriesMetadata.instances, filters) && + !issuedWarningSeries.includes(seriesInstanceUIDs[0]) + ) { + // stores the series instance filter so it shows only once the warning + issuedWarningSeries.push(seriesInstanceUIDs[0]); + uiNotificationService.show({ + title: 'Series filter', + message: `Each of the series in filter: ${seriesInstanceUIDs} are not part of the current study. The entire study is being displayed`, + type: 'error', + duration: 7000, + }); + } + + displaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient); + } + ); + + unsubscriptions.push(instanceAddedUnsubscribe); + + log.time(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS); + log.time(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE); + + const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID => + dataSource.retrieve.series.metadata({ + StudyInstanceUID, + filters, + returnPromises: true, + sortCriteria: + customizationService.get('sortingCriteria') || + sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria, + }) + ); + + // log the error if this fails, otherwise it's so difficult to tell what went wrong... + allRetrieves.forEach(retrieve => { + retrieve.catch(error => { + console.error(error); + }); + }); + + Promise.allSettled(allRetrieves).then(promises => { + log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS); + log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); + log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); + + const allPromises = []; + const remainingPromises = []; + + function startRemainingPromises(remainingPromises) { + remainingPromises.forEach(p => p.forEach(p => p.start())); + } + + promises.forEach(promise => { + const retrieveSeriesMetadataPromise = promise.value; + if (Array.isArray(retrieveSeriesMetadataPromise)) { + const { requiredSeries, remaining } = hangingProtocolService.filterSeriesRequiredForRun( + hangingProtocolId, + retrieveSeriesMetadataPromise + ); + const requiredSeriesPromises = requiredSeries.map(promise => promise.start()); + allPromises.push(Promise.allSettled(requiredSeriesPromises)); + remainingPromises.push(remaining); + } + }); + + Promise.allSettled(allPromises).then(applyHangingProtocol); + startRemainingPromises(remainingPromises); + applyHangingProtocol(); + }); + + return unsubscriptions; +} diff --git a/platform/app/src/routes/Mode/updateAuthServiceAndCleanUrl.ts b/platform/app/src/routes/Mode/updateAuthServiceAndCleanUrl.ts new file mode 100644 index 000000000..c387d69cc --- /dev/null +++ b/platform/app/src/routes/Mode/updateAuthServiceAndCleanUrl.ts @@ -0,0 +1,35 @@ +/** + * Updates the user authentication service with the provided token and cleans the token from the URL. + * @param token - The token to set in the user authentication service. + * @param location - The location object from the router. + * @param userAuthenticationService - The user authentication service instance. + */ +export function updateAuthServiceAndCleanUrl( + token: string, + location: any, + userAuthenticationService: any +): void { + if (!token) { + return; + } + + // if a token is passed in, set the userAuthenticationService to use it + // for the Authorization header for all requests + userAuthenticationService.setServiceImplementation({ + getAuthorizationHeader: () => ({ + Authorization: 'Bearer ' + token, + }), + }); + + // Create a URL object with the current location + const urlObj = new URL(window.location.origin + window.location.pathname + location.search); + + // Remove the token from the URL object + urlObj.searchParams.delete('token'); + const cleanUrl = urlObj.toString(); + + // Update the browser's history without the token + if (window.history && window.history.replaceState) { + window.history.replaceState(null, '', cleanUrl); + } +} diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index 05a5e0994..c4c9c6b83 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -419,7 +419,7 @@ function WorkList({ /> } // launch-arrow | launch-info onClick={() => {}} - data-cy={`mode-${mode.routeName}-${studyInstanceUid}`} + dataCY={`mode-${mode.routeName}-${studyInstanceUid}`} className={isValidMode ? 'text-[13px]' : 'bg-[#222d44] text-[13px]'} > {mode.displayName} diff --git a/platform/cli/templates/mode/src/index.tsx b/platform/cli/templates/mode/src/index.tsx index e129a9a05..7ff747bbe 100644 --- a/platform/cli/templates/mode/src/index.tsx +++ b/platform/cli/templates/mode/src/index.tsx @@ -48,37 +48,7 @@ function modeFactory({ modeConfiguration }) { // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); - let unsubscribe; - - const activateTool = () => { - toolbarService.recordInteraction({ - groupId: 'WindowLevel', - interactionType: 'tool', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - }); - - // We don't need to reset the active tool whenever a viewport is getting - // added to the toolGroup. - unsubscribe(); - }; - - // Since we only have one viewport for the basic cs3d mode and it has - // only one hanging protocol, we can just use the first viewport - ({ unsubscribe } = toolGroupService.subscribe( - toolGroupService.EVENTS.VIEWPORT_ADDED, - activateTool - )); - - toolbarService.init(extensionManager); - toolbarService.addButtons([...toolbarButtons, ...moreTools]); + toolbarService.addButtons(toolbarButtons); toolbarService.createButtonSection('primary', [ 'MeasurementTools', 'Zoom', diff --git a/platform/core/src/classes/CommandsManager.test.js b/platform/core/src/classes/CommandsManager.test.js index 399003834..390f9d4df 100644 --- a/platform/core/src/classes/CommandsManager.test.js +++ b/platform/core/src/classes/CommandsManager.test.js @@ -8,7 +8,6 @@ describe('CommandsManager', () => { contextName = 'VTK', command = { commandFn: jest.fn().mockReturnValue(true), - storeContexts: ['viewers'], options: { passMeToCommandFn: ':wave:' }, }, commandsManagerConfig = { @@ -139,7 +138,6 @@ describe('CommandsManager', () => { it('Logs a warning if command definition does not have a commandFn', () => { const commandWithNoCommmandFn = { commandFn: undefined, - storeContexts: [], options: {}, }; diff --git a/platform/core/src/classes/CommandsManager.ts b/platform/core/src/classes/CommandsManager.ts index 4d4563a00..820554ca2 100644 --- a/platform/core/src/classes/CommandsManager.ts +++ b/platform/core/src/classes/CommandsManager.ts @@ -1,5 +1,5 @@ import log from '../log.js'; -import { Command, Commands } from '../types/Command'; +import { Command, Commands, ComplexCommand } from '../types/Command'; /** * The definition of a command @@ -167,28 +167,44 @@ export class CommandsManager { * Run one or more commands with specified extra options. * Returns the result of the last command run. * - * @param toRun - A specification of one or more commands + * @param toRun - A specification of one or more commands, + * typically an object of { commandName, commandOptions, context } + * or an array of such objects. It can also be a single commandName as string + * if no options are needed. * @param options - to include in the commands run beyond * the commandOptions specified in the base. */ public run( - toRun: Command | Commands | Command[] | undefined, + toRun: Command | Commands | Command[] | string | undefined, options?: Record ): unknown { if (!toRun) { return; } - const commands = - (Array.isArray(toRun) && toRun) || - ((toRun as Command).commandName && [toRun]) || - (Array.isArray((toRun as Commands).commands) && (toRun as Commands).commands); - if (!commands) { + + // Normalize `toRun` to an array of `ComplexCommand` + let commands: ComplexCommand[] = []; + if (typeof toRun === 'string') { + commands = [{ commandName: toRun }]; + } else if ('commandName' in toRun) { + commands = [toRun as ComplexCommand]; + } else if ('commands' in toRun) { + const commandsInput = (toRun as Commands).commands; + commands = Array.isArray(commandsInput) + ? commandsInput.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd)) + : [{ commandName: commandsInput }]; + } else if (Array.isArray(toRun)) { + commands = toRun.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd)); + } + + if (commands.length === 0) { console.log("Command isn't runnable", toRun); return; } - let result; - (commands as Command[]).forEach(({ commandName, commandOptions, context }) => { + // Execute each command in the array + let result: unknown; + commands.forEach(({ commandName, commandOptions, context }) => { if (commandName) { result = this.runCommand( commandName, diff --git a/platform/core/src/classes/HotkeysManager.ts b/platform/core/src/classes/HotkeysManager.ts index 886cafc2d..8a01446b6 100644 --- a/platform/core/src/classes/HotkeysManager.ts +++ b/platform/core/src/classes/HotkeysManager.ts @@ -1,5 +1,4 @@ import objectHash from 'object-hash'; -import log from '../log.js'; import { hotkeys } from '../utils'; import isequal from 'lodash.isequal'; import Hotkey from './Hotkey'; @@ -276,10 +275,3 @@ export class HotkeysManager { } export default HotkeysManager; - -// Commands Contexts: - -// --> Name and Priority -// GLOBAL: 0 -// VIEWER::CORNERSTONE: 1 -// VIEWER::VTK: 1 diff --git a/platform/core/src/extensions/ExtensionManager.test.js b/platform/core/src/extensions/ExtensionManager.test.js index 5d5fed85b..042a49884 100644 --- a/platform/core/src/extensions/ExtensionManager.test.js +++ b/platform/core/src/extensions/ExtensionManager.test.js @@ -247,7 +247,6 @@ describe('ExtensionManager.ts', () => { definitions: { exampleDefinition: { commandFn: () => {}, - storeContexts: [], options: {}, }, }, diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index 083fde7f2..7f6138344 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -45,6 +45,7 @@ export interface Extension { getUtilityModule?: (p: ExtensionParams) => unknown; getCustomizationModule?: (p: ExtensionParams) => unknown; getSopClassHandlerModule?: (p: ExtensionParams) => unknown; + getToolbarModule?: (p: ExtensionParams) => unknown; onModeEnter?: () => void; onModeExit?: () => void; } @@ -65,9 +66,24 @@ export default class ExtensionManager extends PubSubService { ACTIVE_DATA_SOURCE_CHANGED: 'event::activedatasourcechanged', }; + public static readonly MODULE_TYPES = MODULE_TYPES; + private _commandsManager: CommandsManager; private _servicesManager: ServicesManager; private _hotkeysManager: HotkeysManager; + private modulesMap: Record; + private modules: Record; + private registeredExtensionIds: string[]; + private moduleTypeNames: string[]; + private _appConfig: any; + private _extensionLifeCycleHooks: { + onModeEnter: Record; + onModeExit: Record; + }; + private dataSourceMap: Record; + private dataSourceDefs: Record; + private defaultDataSourceName: string; + private activeDataSource: string; constructor({ commandsManager, @@ -265,57 +281,74 @@ export default class ExtensionManager extends PubSubService { configuration ); - if (extensionModule) { - switch (moduleType) { - case MODULE_TYPES.COMMANDS: - this._initCommandsModule(extensionModule); - break; - case MODULE_TYPES.DATA_SOURCE: - this._initDataSourcesModule(extensionModule, extensionId, dataSources); - break; - case MODULE_TYPES.HANGING_PROTOCOL: - this._initHangingProtocolsModule(extensionModule, extensionId); - case MODULE_TYPES.TOOLBAR: - case MODULE_TYPES.VIEWPORT: - case MODULE_TYPES.PANEL: - case MODULE_TYPES.SOP_CLASS_HANDLER: - case MODULE_TYPES.CONTEXT: - case MODULE_TYPES.LAYOUT_TEMPLATE: - case MODULE_TYPES.CUSTOMIZATION: - case MODULE_TYPES.STATE_SYNC: - case MODULE_TYPES.UTILITY: - // Default for most extension points, - // Just adds each entry ready for consumption by mode. - extensionModule.forEach(element => { - if (!element.name) { - throw new Error( - `Extension ID ${extensionId} module ${moduleType} element has no name` - ); - } - const id = `${extensionId}.${moduleType}.${element.name}`; - element.id = id; - this.modulesMap[id] = element; - }); - break; - default: - throw new Error(`Module type invalid: ${moduleType}`); - } - - this.modules[moduleType].push({ - extensionId, - module: extensionModule, - }); + if (!extensionModule) { + return; } + + switch (moduleType) { + case MODULE_TYPES.COMMANDS: + this._initCommandsModule(extensionModule); + break; + + case MODULE_TYPES.DATA_SOURCE: + this._initDataSourcesModule(extensionModule, extensionId, dataSources); + break; + + case MODULE_TYPES.HANGING_PROTOCOL: + this._initHangingProtocolsModule(extensionModule, extensionId); + break; + + case MODULE_TYPES.PANEL: + this._initPanelModule(extensionModule, extensionId); + break; + + case MODULE_TYPES.TOOLBAR: + this._initToolbarModule(extensionModule, extensionId); + break; + + case MODULE_TYPES.VIEWPORT: + case MODULE_TYPES.SOP_CLASS_HANDLER: + case MODULE_TYPES.CONTEXT: + case MODULE_TYPES.LAYOUT_TEMPLATE: + case MODULE_TYPES.CUSTOMIZATION: + case MODULE_TYPES.STATE_SYNC: + case MODULE_TYPES.UTILITY: + this.processExtensionModule(extensionModule, extensionId, moduleType); + break; + default: + throw new Error(`Module type invalid: ${moduleType}`); + } + + this.modules[moduleType].push({ + extensionId, + module: extensionModule, + }); }); // Track extension registration this.registeredExtensionIds.push(extensionId); }; + /** + * Retrieves the module entry associated with the given string entry + * @param stringEntry - The string entry to retrieve the module entry for which is + * in the format of `${extensionId}.${moduleType}.${moduleName}` + * @returns The module entry associated with the given string entry. + */ getModuleEntry = stringEntry => { return this.modulesMap[stringEntry]; }; + /** + * Retrieves all modules of a given type for all registered extensions. + * + * @param moduleType - The type of modules to retrieve. + * @returns An array of modules of the specified type. + */ + getModulesByType = (moduleType: string) => { + return this.modules[moduleType]; + }; + getDataSources = dataSourceName => { if (dataSourceName === undefined) { // Default to the activeDataSource @@ -402,6 +435,38 @@ export default class ExtensionManager extends PubSubService { }); }; + _initPanelModule = (extensionModule, extensionId) => { + this.processExtensionModule(extensionModule, extensionId, MODULE_TYPES.PANEL); + }; + + _initToolbarModule = (extensionModule, extensionId) => { + // check if the toolbar module has a handler function for evaluation of + // the toolbar button state + const { toolbarService } = this._servicesManager.services; + extensionModule.forEach(toolbarButton => { + if (toolbarButton.evaluate) { + toolbarService.registerEvaluateFunction(toolbarButton.name, toolbarButton.evaluate); + } + }); + }; + + /** + * Processes an extension module. + * @param extensionModule - The extension module to process. + * @param extensionId - The ID of the extension. + * @param moduleType - The type of the module. + */ + private processExtensionModule(extensionModule, extensionId: string, moduleType: string) { + extensionModule.forEach(element => { + if (!element.name) { + throw new Error(`Extension ID ${extensionId} module ${moduleType} element has no name`); + } + const id = `${extensionId}.${moduleType}.${element.name}`; + element.id = id; + this.modulesMap[id] = element; + }); + } + /** * Adds the given data source and optionally sets it as the active data source. * The method does this by first creating the data source. diff --git a/platform/core/src/hooks/useToolbar.tsx b/platform/core/src/hooks/useToolbar.tsx new file mode 100644 index 000000000..31eeb1d62 --- /dev/null +++ b/platform/core/src/hooks/useToolbar.tsx @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useState } from 'react'; + +export function useToolbar({ servicesManager, buttonSection = 'primary' }) { + const { toolbarService, viewportGridService } = servicesManager.services; + const { EVENTS } = toolbarService; + + const [toolbarButtons, setToolbarButtons] = useState( + toolbarService.getButtonSection(buttonSection) + ); + + // Callback function for handling toolbar interactions + const onInteraction = useCallback( + args => { + const viewportId = viewportGridService.getActiveViewportId(); + const refreshProps = { + viewportId, + }; + toolbarService.recordInteraction(args, { + refreshProps, + }); + }, + [toolbarService, viewportGridService] + ); + + // Effect to handle toolbar modification events + useEffect(() => { + const handleToolbarModified = () => { + setToolbarButtons(toolbarService.getButtonSection(buttonSection)); + }; + + const subs = [EVENTS.TOOL_BAR_MODIFIED, EVENTS.TOOL_BAR_STATE_MODIFIED].map(event => { + return toolbarService.subscribe(event, handleToolbarModified); + }); + + return () => { + subs.forEach(sub => sub.unsubscribe()); + }; + }, [toolbarService]); + + // Effect to handle active viewportId change event + useEffect(() => { + const events = [ + viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, + viewportGridService.EVENTS.VIEWPORTS_READY, + ]; + + const subscriptions = events.map(event => { + return viewportGridService.subscribe(event, ({ viewportId }) => { + viewportId = viewportId || viewportGridService.getActiveViewportId(); + toolbarService.refreshToolbarState({ viewportId }); + }); + }); + + return () => subscriptions.forEach(sub => sub.unsubscribe()); + }, [viewportGridService, toolbarService]); + + return { toolbarButtons, onInteraction }; +} diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index 7c000ae59..3c0922200 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -45,6 +45,7 @@ describe('Top level exports', () => { 'pubSubServiceInterface', 'PubSubService', 'PanelService', + 'useToolbar', ].sort(); const exports = Object.keys(OHIF).sort(); diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index ddea2c457..13612d775 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -12,6 +12,7 @@ import utils from './utils'; import defaults from './defaults'; import * as Types from './types'; import * as Enums from './enums'; +import { useToolbar } from './hooks/useToolbar'; import { CineService, @@ -81,6 +82,7 @@ const OHIF = { pubSubServiceInterface, PubSubService, PanelService, + useToolbar, }; export { @@ -124,6 +126,7 @@ export { Enums, Types, PanelService, + useToolbar, }; export { OHIF }; diff --git a/platform/core/src/services/CineService/CineService.ts b/platform/core/src/services/CineService/CineService.ts index 153a186d5..9c027afb2 100644 --- a/platform/core/src/services/CineService/CineService.ts +++ b/platform/core/src/services/CineService/CineService.ts @@ -1,82 +1,80 @@ -const name = 'CineService'; +import { PubSubService } from '../_shared/pubSubServiceInterface'; -const publicAPI = { - name, - getState: _getState, - setCine: _setCine, - setIsCineEnabled: _setIsCineEnabled, - playClip: _playClip, - stopClip: _stopClip, - onModeExit: _onModeExit, - setServiceImplementation, -}; +class CineService extends PubSubService { + public static readonly EVENTS = { + CINE_STATE_CHANGED: 'event::cineStateChanged', + }; -const serviceImplementation = { - _getState: () => console.warn('getState() NOT IMPLEMENTED'), - _setCine: () => console.warn('setCine() NOT IMPLEMENTED'), - _playClip: () => console.warn('playClip() NOT IMPLEMENTED'), - _stopClip: () => console.warn('stopClip() NOT IMPLEMENTED'), - _setIsCineEnabled: () => console.warn('setIsCineEnabled() NOT IMPLEMENTED'), -}; - -function _getState() { - return serviceImplementation._getState(); -} - -function _setCine({ id, frameRate, isPlaying }) { - return serviceImplementation._setCine({ id, frameRate, isPlaying }); -} - -function _setIsCineEnabled(isCineEnabled) { - return serviceImplementation._setIsCineEnabled(isCineEnabled); -} - -function _playClip(element, playClipOptions) { - return serviceImplementation._playClip(element, playClipOptions); -} - -function _stopClip(element) { - return serviceImplementation._stopClip(element); -} - -function _onModeExit() { - _setIsCineEnabled(false); -} - -function setServiceImplementation({ - getState: getStateImplementation, - setCine: setCineImplementation, - setIsCineEnabled: setIsCineEnabledImplementation, - playClip: playClipImplementation, - stopClip: stopClipImplementation, -}) { - if (getStateImplementation) { - serviceImplementation._getState = getStateImplementation; - } - if (setCineImplementation) { - serviceImplementation._setCine = setCineImplementation; - } - if (setIsCineEnabledImplementation) { - serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation; - } - - if (playClipImplementation) { - serviceImplementation._playClip = playClipImplementation; - } - - if (stopClipImplementation) { - serviceImplementation._stopClip = stopClipImplementation; - } -} - -const CineService = { - REGISTRATION: { - altName: name, + public static REGISTRATION = { name: 'cineService', + altName: 'CineService', create: ({ configuration = {} }) => { - return publicAPI; + return new CineService(); }, - }, -}; + }; + + serviceImplementation = {}; + + constructor() { + super(CineService.EVENTS); + this.serviceImplementation = {}; + } + + public getState() { + return this.serviceImplementation._getState(); + } + + public setCine({ id, frameRate, isPlaying }) { + return this.serviceImplementation._setCine({ id, frameRate, isPlaying }); + } + + public setIsCineEnabled(isCineEnabled) { + this.serviceImplementation._setIsCineEnabled(isCineEnabled); + // Todo: for some reason i need to do this setTimeout since the + // reducer state does not get updated right away and if we publish the + // event and we use the cineService.getState() it will return the old state + setTimeout(() => { + this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, isCineEnabled); + }, 0); + } + + public playClip(element, playClipOptions) { + return this.serviceImplementation._playClip(element, playClipOptions); + } + + public stopClip(element) { + return this.serviceImplementation._stopClip(element); + } + + public _onModeExit() { + this.setIsCineEnabled(false); + } + + public setServiceImplementation({ + getState: getStateImplementation, + setCine: setCineImplementation, + setIsCineEnabled: setIsCineEnabledImplementation, + playClip: playClipImplementation, + stopClip: stopClipImplementation, + }) { + if (getStateImplementation) { + this.serviceImplementation._getState = getStateImplementation; + } + if (setCineImplementation) { + this.serviceImplementation._setCine = setCineImplementation; + } + if (setIsCineEnabledImplementation) { + this.serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation; + } + + if (playClipImplementation) { + this.serviceImplementation._playClip = playClipImplementation; + } + + if (stopClipImplementation) { + this.serviceImplementation._stopClip = stopClipImplementation; + } + } +} export default CineService; diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index be8639d5e..0d97a3307 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -135,15 +135,15 @@ export default class CustomizationService extends PubSubService { return this.getModeCustomization(customizationId, defaultValue); } - /** Mode customizations are changes to the behaviour of the extensions + /** Mode customizations are changes to the behavior of the extensions * when running in a given mode. Reset clears mode customizations. * Note that global customizations over-ride mode customizations. - * @param defautlValue to return if no customization specified. + * @param defaultValue to return if no customization specified. */ public getModeCustomization( customizationId: string, defaultValue?: Customization - ): Customization | void { + ): Customization { const customization = this.globalCustomizations[customizationId] ?? this.modeCustomizations[customizationId] ?? diff --git a/platform/core/src/services/MeasurementService/MeasurementService.ts b/platform/core/src/services/MeasurementService/MeasurementService.ts index 7d253ecff..49ce726f0 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.ts +++ b/platform/core/src/services/MeasurementService/MeasurementService.ts @@ -113,7 +113,7 @@ class MeasurementService extends PubSubService { public readonly VALUE_TYPES = VALUE_TYPES; private measurements = new Map(); - private unmappedMeasurements = new Set(); + private unmappedMeasurements = new Map(); constructor() { super(EVENTS); @@ -485,7 +485,15 @@ class MeasurementService extends PubSubService { measurement = toMeasurementSchema(sourceAnnotationDetail); measurement.source = source; } catch (error) { - this.unmappedMeasurements.add(sourceAnnotationDetail.uid); + // Todo: handle other + this.unmappedMeasurements.set(sourceAnnotationDetail.uid, { + ...sourceAnnotationDetail, + source: { + name: source.name, + version: source.version, + uid: source.uid, + }, + }); console.log('Failed to map', error); throw new Error( @@ -567,10 +575,9 @@ class MeasurementService extends PubSubService { } clearMeasurements() { - this.unmappedMeasurements.clear(); - // Make a copy of the measurements - const measurements = [...this.measurements.values()]; + const measurements = [...this.measurements.values(), ...this.unmappedMeasurements.values()]; + this.unmappedMeasurements.clear(); this.measurements.clear(); this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements }); } diff --git a/platform/core/src/services/ServicesManager.ts b/platform/core/src/services/ServicesManager.ts index 445e5aec3..b7419639a 100644 --- a/platform/core/src/services/ServicesManager.ts +++ b/platform/core/src/services/ServicesManager.ts @@ -1,9 +1,13 @@ import log from './../log.js'; import Services from '../types/Services'; import CommandsManager from '../classes/CommandsManager'; +import ExtensionManager from '../extensions/ExtensionManager'; export default class ServicesManager { public services: Services = {}; + public registeredServiceNames: string[] = []; + private _commandsManager: CommandsManager; + private _extensionManager: ExtensionManager; constructor(commandsManager: CommandsManager) { this._commandsManager = commandsManager; @@ -11,6 +15,10 @@ export default class ServicesManager { this.registeredServiceNames = []; } + setExtensionManager(extensionManager) { + this._extensionManager = extensionManager; + } + /** * Registers a new service. * @@ -40,6 +48,7 @@ export default class ServicesManager { configuration, commandsManager: this._commandsManager, servicesManager: this, + extensionManager: this._extensionManager, }); if (service.altName) { console.log('Registering old name', service.altName); diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index 269f93da4..eff3912ab 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -1,109 +1,79 @@ -import merge from 'lodash.merge'; import { CommandsManager } from '../../classes'; import { ExtensionManager } from '../../extensions'; import { PubSubService } from '../_shared/pubSubServiceInterface'; -import type { RunCommand, Commands } from '../../types/Command'; +import type { RunCommand } from '../../types/Command'; +import ServicesManager from '../ServicesManager'; +import { Button, ButtonProps, EvaluateFunction, EvaluatePublic, NestedButtonProps } from './types'; const EVENTS = { TOOL_BAR_MODIFIED: 'event::toolBarService:toolBarModified', TOOL_BAR_STATE_MODIFIED: 'event::toolBarService:toolBarStateModified', }; -export type ButtonListeners = Record; - -export interface ButtonProps { - primary?: Button; - secondary?: Button; - items?: Button[]; -} - -export interface Button extends Commands { - id: string; - icon?: string; - label?: string; - type?: string; - tooltip?: string; - isActive?: boolean; - listeners?: ButtonListeners; - props?: ButtonProps; -} - -export interface ExtraButtonOptions { - listeners?: ButtonListeners; - isActive?: boolean; -} - export default class ToolbarService extends PubSubService { public static REGISTRATION = { name: 'toolbarService', - // Note the old name is ToolBarService, with an upper B altName: 'ToolBarService', - create: ({ commandsManager }) => { - return new ToolbarService(commandsManager); + create: ({ commandsManager, extensionManager, servicesManager }) => { + return new ToolbarService(commandsManager, extensionManager, servicesManager); }, }; - public static _createButton( - type: string, - id: string, - icon: string, - label: string, - commands: Command | Commands, - tooltip?: string, - extraOptions?: ExtraButtonOptions - ): Button { + public static createButton(options: { + id: string; + label: string; + commands: RunCommand; + icon?: string; + tooltip?: string; + evaluate?: EvaluatePublic; + listeners?: Record; + }): ButtonProps { + const { id, icon, label, commands, tooltip, evaluate, listeners } = options; return { id, icon, label, - type, commands, - tooltip, - ...extraOptions, + tooltip: tooltip || label, + evaluate, + listeners, }; } - public static _createActionButton = ToolbarService._createButton.bind(null, 'action'); - public static _createToggleButton = ToolbarService._createButton.bind(null, 'toggle'); - public static _createToolButton = ToolbarService._createButton.bind(null, 'tool'); - - buttons: Record = {}; state: { - primaryToolId: string; - toggles: Record; - groups: Record; - } = { primaryToolId: '', toggles: {}, groups: {} }; - - buttonSections: Record = { - /** - * primary: ['Zoom', 'Wwwc'], - * secondary: ['Length', 'RectangleRoi'] - */ + // all buttons in the toolbar with their props + buttons: Record; + // the buttons in the toolbar, grouped by section, with their ids + buttonSections: Record; + } = { + buttons: {}, + buttonSections: {}, }; + _commandsManager: CommandsManager; - extensionManager: ExtensionManager; + _extensionManager: ExtensionManager; + _servicesManager: ServicesManager; + _evaluateFunction: Record = {}; + _serviceSubscriptions = []; - defaultTool: Record; - - constructor(commandsManager: CommandsManager) { + constructor( + commandsManager: CommandsManager, + extensionManager: ExtensionManager, + servicesManager: ServicesManager + ) { super(EVENTS); this._commandsManager = commandsManager; - } - - public init(extensionManager: ExtensionManager): void { - this.extensionManager = extensionManager; + this._extensionManager = extensionManager; + this._servicesManager = servicesManager; } public reset(): void { this.unsubscriptions.forEach(unsub => unsub()); this.state = { - primaryToolId: 'WindowLevel', - toggles: {}, - groups: {}, + buttons: {}, + buttonSections: {}, }; this.unsubscriptions = []; - this.buttonSections = {}; - this.buttons = {}; } public onModeEnter(): void { @@ -111,16 +81,52 @@ export default class ToolbarService extends PubSubService { } /** - * Sets the default tool that will be activated whenever the primary tool is - * deactivated without activating another/different tool. - * @param interaction the interaction command that will set the default tool active + * Registers an evaluate function with the specified name. + * + * @param name - The name of the evaluate function. + * @param handler - The evaluate function handler. */ - public setDefaultTool(interaction) { - this.defaultTool = interaction; + public registerEvaluateFunction(name: string, handler: EvaluateFunction) { + this._evaluateFunction[name] = handler; } - public getDefaultTool() { - return this.defaultTool; + /** + * Registers a service and its event to listen for updates and refreshes the toolbar state when the event is triggered. + * @param service - The service to register. + * @param event - The event to listen for. + */ + public registerEventForToolbarUpdate(service, events) { + const { viewportGridService } = this._servicesManager.services; + const callback = () => { + const viewportId = viewportGridService.getActiveViewportId(); + this.refreshToolbarState({ viewportId }); + }; + + const unsubscriptions = events.map(event => { + if (service.subscribe) { + return service.subscribe(event, callback); + } else if (service.addEventListener) { + return service.addEventListener(event, callback); + } + }); + + unsubscriptions.forEach(unsub => this._serviceSubscriptions.push(unsub)); + } + + /** + * Adds buttons to the toolbar. + * @param buttons - The buttons to be added. + */ + public addButtons(buttons: Button[]): void { + buttons.forEach(button => { + if (!this.state.buttons[button.id]) { + this.state.buttons[button.id] = button; + } + }); + + this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { + ...this.state, + }); } /** @@ -130,160 +136,180 @@ export default class ToolbarService extends PubSubService { * used for calling the specified interaction. That is, the command is * called with {...commandOptions,...options} */ - public recordInteraction(interaction, options?: Record) { - if (!interaction) { - return; + public recordInteraction( + interaction, + options?: { + refreshProps: Record; + [key: string]: unknown; } - const commandsManager = this._commandsManager; - const { groupId, itemId, commands, type } = interaction; - let { interactionType } = interaction; - - // if not interaction type, assume the type can be used - if (!interactionType) { - interactionType = type; + ) { + // if interaction is a string, we can assume it is the itemId + // and get the props to get the other properties + if (typeof interaction === 'string') { + interaction = this.getButtonProps(interaction); } - switch (interactionType) { - case 'action': { - commandsManager.run(commands, options); - break; - } - case 'tool': { - try { - const alternateInteraction = - this.state.primaryToolId === itemId && - this.defaultTool?.itemId !== itemId && - this.getDefaultTool(); - if (alternateInteraction) { - // Allow toggling the mode off - return this.recordInteraction(alternateInteraction, options); - } - commandsManager.run(commands, options); + const itemId = interaction.itemId ?? interaction.id; + interaction.itemId = itemId; - // only set the primary tool if no error was thrown. - // if the itemId is not undefined use it; otherwise, set the first tool in - // the commands as the primary tool - this.state.primaryToolId = itemId || commands[0].commandOptions?.toolName; - } catch (error) { - console.warn(error); - } + const commands = Array.isArray(interaction.commands) + ? interaction.commands + : [interaction.commands]; - break; - } - case 'toggle': { - const { commands } = interaction; - let commandExecuted; - - // only toggle if a command was executed - this.state.toggles[itemId] = - this.state.toggles[itemId] === undefined ? true : !this.state.toggles[itemId]; - - if (!commands) { - break; - } - - commands.forEach(({ commandName, commandOptions, context }) => { - if (!commandOptions) { - commandOptions = {}; - } - - if (commandName) { - commandOptions.toggledState = this.state.toggles[itemId]; - - try { - commandsManager.runCommand(commandName, commandOptions, context); - commandExecuted = true; - } catch (error) { - console.warn(error); - } - } - }); - - if (!commandExecuted) { - // If no command was executed, we need to toggle the state back - this.state.toggles[itemId] = !this.state.toggles[itemId]; - } - - break; - } - default: - throw new Error(`Invalid interaction type: ${interactionType}`); - } - - // Todo: comment out for now - // Run command if there's one associated - // - // NOTE: Should probably just do this for tools as well? - // But would be nice if we could enforce at least the command name? - // let unsubscribe; - // if (commandName) { - // unsubscribe = commandsManager.runCommand(commandName, commandOptions); - // } - - // // Storing the unsubscribe for later resetting - // if (unsubscribe && typeof unsubscribe === 'function') { - // if (this.unsubscriptions.indexOf(unsubscribe) === -1) { - // this.unsubscriptions.push(unsubscribe); - // } - // } - - // Track last touched id for each group - if (groupId) { - this.state.groups[groupId] = itemId; - } - - this._broadcastEvent(this.EVENTS.TOOL_BAR_STATE_MODIFIED, { ...this.state }); - } - - getButtons() { - return this.buttons; - } - - getActiveTools() { - const activeTools = [this.state.primaryToolId]; - Object.keys(this.state.toggles).forEach(key => { - if (this.state.toggles[key]) { - activeTools.push(key); - } - }); - return activeTools; - } - - getActivePrimaryTool() { - return this.state.primaryToolId; - } - - /** Sets the toggle state of a button to the isToggled state */ - public setToggled(id: string, isToggled: boolean): void { - if (isToggled) { - this.state.toggles[id] = true; - } else { - delete this.state.toggles[id]; - } - } - - setButton(id, button) { - if (this.buttons[id]) { - this.buttons[id] = merge(this.buttons[id], button); - this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { - buttons: this.buttons, - button: this.buttons[id], - buttonSections: this.buttonSections, + if (!commands?.length) { + this.refreshToolbarState({ + ...options?.refreshProps, + itemId, + interaction, }); } + + const commandOptions = { ...options, ...interaction }; + + // Loop through commands and run them with the combined options + this._commandsManager.run(commands, commandOptions); + + this.refreshToolbarState({ + ...options?.refreshProps, + itemId, + interaction, + }); } + /** + * Consolidates the state of the toolbar after an interaction, it accepts + * props that get passed to the buttons + * + * @param refreshProps - The props that buttons need to get evaluated, they can be + * { viewportId, toolGroup} for cornerstoneTools. + * + * Todo: right now refreshToolbarState should be used in the context where + * we have access to the toolGroup and viewportId, but we should be able to + * pass the props to the toolbar service and it should be able to decide + * which buttons to evaluate based on the props + */ + public refreshToolbarState(refreshProps) { + const buttons = this.state.buttons; + + // Tracks evaluated buttons to avoid re-evaluating them (this will + // cause issue for toggles where if the button is in primary + // and secondary it will be evaluated twice) + const evaluationResults = new Map(); + + const evaluateButtonProps = (button, props, refreshProps) => { + if (evaluationResults.has(button.id)) { + const { disabled, className, isActive } = evaluationResults.get(button.id); + return { ...props, disabled, className, isActive }; + } else { + const evaluated = props.evaluate?.({ ...refreshProps, button }); + const updatedProps = { + ...props, + disabled: evaluated?.disabled || false, + className: evaluated?.className || '', + isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop + }; + evaluationResults.set(button.id, updatedProps); + return updatedProps; + } + }; + + const refreshedButtons = Object.values(buttons).reduce((acc, button: Button) => { + const isNested = (button.props as NestedButtonProps)?.groupId; + + if (!isNested) { + this.handleEvaluate(button.props); + const buttonProps = button.props as ButtonProps; + + const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps); + acc[button.id] = { + ...button, + props: updatedProps, + }; + } else { + let buttonProps = button.props as NestedButtonProps; + // if it is nested we should perform evaluate on each item in the group + this.handleEvaluateNested(buttonProps); + + const { evaluate: groupEvaluate } = buttonProps; + + const groupEvaluated = groupEvaluate?.({ ...refreshProps, button }); + // handle group evaluate function which might switch the primary + // item in the group + buttonProps = { + ...buttonProps, + primary: groupEvaluated?.primary || buttonProps.primary, + }; + + const { primary, items } = buttonProps; + + // primary and items evaluate functions + let updatedPrimary; + if (primary) { + updatedPrimary = evaluateButtonProps(primary, primary, refreshProps); + } + const updatedItems = items.map(item => evaluateButtonProps(item, item, refreshProps)); + buttonProps = { + ...buttonProps, + primary: updatedPrimary, + items: updatedItems, + }; + + acc[button.id] = { + ...button, + props: buttonProps, + }; + } + + return acc; + }, {}); + + this.setButtons(refreshedButtons); + return this.state; + } + + /** + * Sets the buttons for the toolbar, don't use this method to record an + * interaction, since it doesn't update the state of the buttons, use + * this if you know the buttons you want to set and you want to set them + * all at once. + * @param buttons - The buttons to set. + */ + public setButtons(buttons) { + this.state.buttons = buttons; + this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { + buttons: this.state.buttons, + buttonSections: this.state.buttonSections, + }); + } + + /** + * Retrieves a button by its ID. + * @param id - The ID of the button to retrieve. + * @returns The button with the specified ID. + */ public getButton(id: string): Button { - return this.buttons[id]; + return this.state.buttons[id]; } - /** Gets a nested button, found in the items/props for the children */ - public getNestedButton(id: string): Button { - if (this.buttons[id]) { - return this.buttons[id]; - } - for (const buttonId of Object.keys(this.buttons)) { - const { primary, items } = this.buttons[buttonId].props || {}; + /** + * Retrieves the buttons from the toolbar service. + * @returns An array of buttons. + */ + public getButtons() { + return this.state.buttons; + } + + /** + * Retrieves the button properties for the specified button ID. + * It prioritizes nested buttons over regular buttons if the ID is found + * in both. + * + * @param id - The ID of the button. + * @returns The button properties. + */ + public getButtonProps(id: string): ButtonProps { + for (const buttonId of Object.keys(this.state.buttons)) { + const { primary, items } = (this.state.buttons[buttonId].props as NestedButtonProps) || {}; if (primary?.id === id) { return primary; } @@ -292,97 +318,57 @@ export default class ToolbarService extends PubSubService { return found; } } + + // This should be checked after we checked the nested buttons, since + // we are checking based on the ids, the nested objects are higher priority + // and more specific + if (this.state.buttons[id]) { + return this.state.buttons[id].props as ButtonProps; + } } - setButtons(buttons) { - this.buttons = buttons; - this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { - buttons: this.buttons, - buttonSections: this.buttonSections, - }); - } + _getButtonUITypes() { + const registeredToolbarModules = this._extensionManager.modules['toolbarModule']; - _buttonTypes() { - const buttonTypes = {}; - const registeredToolbarModules = this.extensionManager.modules['toolbarModule']; - - if (Array.isArray(registeredToolbarModules) && registeredToolbarModules.length) { - registeredToolbarModules.forEach(toolbarModule => - toolbarModule.module.forEach(def => { - buttonTypes[def.name] = def; - }) - ); + if (!Array.isArray(registeredToolbarModules)) { + return {}; } - return buttonTypes; - } - - createButtonSection(key, buttons) { - // Maybe do this mapping at time of return, instead of time of create - // Props check important for validation here... - - this.buttonSections[key] = buttons; - this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {}); - } - - /** - * - * Finds a button section by it's name/tool group id, then maps the list of string name - * identifiers to schema/values that can be used to render the buttons. - * - * @param toolGroupId - the tool group id - * @param props - optional properties to apply to every button of the section - * @param defaultToolGroupId - the fallback section to return if the given toolGroupId section is not available - */ - getButtonSection( - toolGroupId: string, - props?: Record, - defaultToolGroupId = 'primary' - ) { - const buttonSectionIds = - this.buttonSections[toolGroupId] || this.buttonSections[defaultToolGroupId]; - const buttonsInSection = []; - - if (buttonSectionIds && buttonSectionIds.length !== 0) { - buttonSectionIds.forEach(btnId => { - const btn = this.buttons[btnId]; - const metadata = {}; - const mappedBtn = this._mapButtonToDisplay(btn, toolGroupId, metadata, props); - - buttonsInSection.push(mappedBtn); + return registeredToolbarModules.reduce((buttonTypes, toolbarModule) => { + toolbarModule.module.forEach(def => { + buttonTypes[def.name] = def; }); - } - return buttonsInSection; + return buttonTypes; + }, {}); } /** - * - * @param {object[]} buttons - * @param {string} buttons[].id + * Creates a button section with the specified key and buttons. + * @param {string} key - The key of the button section. + * @param {Array} buttons - The buttons to be added to the section. */ - addButtons(buttons) { - buttons.forEach(button => { - if (!this.buttons[button.id]) { - this.buttons[button.id] = button; - } - }); - this._setTogglesForButtonItems(buttons); - - this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {}); + createButtonSection(key, buttons) { + this.state.buttonSections[key] = buttons; + this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state }); } - _setTogglesForButtonItems(buttons) { - if (!buttons) { - return; - } + /** + * Retrieves the button section with the specified sectionId. + * + * @param sectionId - The ID of the button section to retrieve. + * @param props - Optional additional properties for mapping the button to display. + * @returns An array of buttons in the specified section, mapped to their display representation. + */ + getButtonSection(sectionId: string, props?: Record) { + const buttonSectionIds = this.state.buttonSections[sectionId]; - buttons.forEach(buttonItem => { - if (buttonItem.type === 'toggle') { - this.setToggled(buttonItem.id, buttonItem.isActive); - } - this._setTogglesForButtonItems(buttonItem.props?.items); - }); + return ( + buttonSectionIds?.map(btnId => { + const btn = this.state.buttons[btnId]; + return this._mapButtonToDisplay(btn, props); + }) || [] + ); } /** @@ -392,18 +378,22 @@ export default class ToolbarService extends PubSubService { * @param {*} metadata * @param {*} props - Props set by the Viewer layer */ - _mapButtonToDisplay(btn, btnSection, metadata, props) { + _mapButtonToDisplay(btn, props) { if (!btn) { return; } - const { id, type, component } = btn; - const buttonType = this._buttonTypes()[type]; + const { id, uiType, component } = btn; + const { groupId } = btn.props; + + const buttonType = this._getButtonUITypes()[uiType]; if (!buttonType) { return; } + !groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props); + return { id, Component: component || buttonType.defaultComponent, @@ -411,7 +401,54 @@ export default class ToolbarService extends PubSubService { }; } + handleEvaluateNested = props => { + const { primary, items } = props; + // handle group evaluate function + this.handleEvaluate(props); + + // primary and items evaluate functions + if (primary) { + this.handleEvaluate(primary); + } + items.forEach(item => this.handleEvaluate(item)); + }; + + handleEvaluate = props => { + const { evaluate } = props; + + if (typeof evaluate === 'function') { + return; + } + + if (typeof evaluate === 'string') { + const evaluateFunction = this._evaluateFunction[evaluate]; + + if (evaluateFunction) { + props.evaluate = evaluateFunction; + return; + } + + throw new Error( + `Evaluate function not found for name: ${evaluate}, you can register an evaluate function with the getToolbarModule in your extensions` + ); + } + + if (typeof evaluate === 'object') { + const { name, options } = evaluate; + const evaluateFunction = this._evaluateFunction[name]; + + if (evaluateFunction) { + props.evaluate = args => evaluateFunction({ ...args, ...options }); + return; + } + + throw new Error( + `Evaluate function not found for name: ${name}, you can register an evaluate function with the getToolbarModule in your extensions` + ); + } + }; + getButtonComponentForUIType(uiType: string) { - return uiType ? this._buttonTypes()[uiType]?.defaultComponent ?? null : null; + return uiType ? this._getButtonUITypes()[uiType]?.defaultComponent ?? null : null; } } diff --git a/platform/core/src/services/ToolBarService/types.ts b/platform/core/src/services/ToolBarService/types.ts new file mode 100644 index 000000000..9b53e5e3a --- /dev/null +++ b/platform/core/src/services/ToolBarService/types.ts @@ -0,0 +1,43 @@ +import type { RunCommand } from '../../types/Command'; + +export type EvaluatePublic = string | EvaluateFunction; + +export type EvaluateFunction = (props: Record) => { + disabled: boolean; + className: string; +}; + +export type ButtonProps = { + id: string; + icon: string; + label: string; + tooltip?: string; + commands?: RunCommand; + disabled?: boolean; + className?: string; + evaluate?: EvaluatePublic; + listeners?: Record; +}; + +export type NestedButtonProps = { + groupId: string; + // group evaluate which is different + // from the evaluate function for the primary and items + evaluate?: EvaluatePublic; + items: ButtonProps[]; + primary: ButtonProps & { + // Todo: this is really ugly but really we don't have any other option + // the ui design requires this since the button should be rounded if + // active otherwise it should not be rounded + isActive?: boolean; + }; + secondary: ButtonProps; +}; + +export type Button = { + id: string; + props: ButtonProps | NestedButtonProps; + // button ui type (e.g. 'ohif.splitButton', 'ohif.radioGroup') + // extensions can provide custom components for these types + uiType: string; +}; diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index 5f2a2be8a..1d2837347 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -7,6 +7,7 @@ class ViewportGridService extends PubSubService { LAYOUT_CHANGED: 'event::layoutChanged', GRID_STATE_CHANGED: 'event::gridStateChanged', GRID_SIZE_CHANGED: 'event::gridSizeChanged', + VIEWPORTS_READY: 'event::viewportsReady', }; public static REGISTRATION = { @@ -35,6 +36,7 @@ class ViewportGridService extends PubSubService { onModeExit: onModeExitImplementation, set: setImplementation, getNumViewportPanes: getNumViewportPanesImplementation, + setViewportIsReady: setViewportIsReadyImplementation, }): void { if (getStateImplementation) { this.serviceImplementation._getState = getStateImplementation; @@ -61,6 +63,14 @@ class ViewportGridService extends PubSubService { if (getNumViewportPanesImplementation) { this.serviceImplementation._getNumViewportPanes = getNumViewportPanesImplementation; } + + if (setViewportIsReadyImplementation) { + this.serviceImplementation._setViewportIsReady = setViewportIsReadyImplementation; + } + } + + public publishViewportsReady() { + this._broadcastEvent(this.EVENTS.VIEWPORTS_READY, {}); } public setActiveViewportId(id: string) { @@ -74,6 +84,10 @@ class ViewportGridService extends PubSubService { return this.serviceImplementation._getState(); } + public setViewportIsReady(viewportId, callback) { + this.serviceImplementation._setViewportIsReady(viewportId, callback); + } + public getActiveViewportId() { const state = this.getState(); return state.activeViewportId; @@ -110,6 +124,17 @@ class ViewportGridService extends PubSubService { }); } + /** + * Retrieves the display set instance UIDs for a given viewport. + * @param viewportId The ID of the viewport. + * @returns An array of display set instance UIDs. + */ + public getDisplaySetsUIDsForViewport(viewportId: string) { + const state = this.getState(); + const viewport = state.viewports.get(viewportId); + return viewport?.displaySetInstanceUIDs; + } + /** * * @param numCols, numRows - the number of columns and rows to apply diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index ab127fb97..3921c7873 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -13,11 +13,11 @@ import HangingProtocolService from './HangingProtocolService'; import pubSubServiceInterface, { PubSubService } from './_shared/pubSubServiceInterface'; import UserAuthenticationService from './UserAuthenticationService'; import CustomizationService from './CustomizationService'; - -import Services from '../types/Services'; import StateSyncService from './StateSyncService'; import PanelService from './PanelService'; +import type Services from '../types/Services'; + export { Services, MeasurementService, diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts index 8a8b8e921..965b4596e 100644 --- a/platform/core/src/types/Command.ts +++ b/platform/core/src/types/Command.ts @@ -1,9 +1,11 @@ -export interface Command { +export type SimpleCommand = string; +export interface ComplexCommand { commandName: string; commandOptions?: Record; context?: string; } +export type Command = SimpleCommand | ComplexCommand; export type RunCommand = Command | Command[]; /** A set of commands, typically contained in a tool item or other configuration */ diff --git a/platform/docs/docs/assets/img/reference-lines-from-start.png b/platform/docs/docs/assets/img/reference-lines-from-start.png new file mode 100644 index 000000000..6e63ee43d Binary files /dev/null and b/platform/docs/docs/assets/img/reference-lines-from-start.png differ diff --git a/platform/docs/docs/assets/img/toolbox-modal.png b/platform/docs/docs/assets/img/toolbox-modal.png new file mode 100644 index 000000000..ce97f732e Binary files /dev/null and b/platform/docs/docs/assets/img/toolbox-modal.png differ diff --git a/platform/docs/docs/migration-guide.md b/platform/docs/docs/migration-guide.md index c252d23c8..9ca0cf8a2 100644 --- a/platform/docs/docs/migration-guide.md +++ b/platform/docs/docs/migration-guide.md @@ -649,7 +649,6 @@ onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { // Init tool groups (see cornerstone3D for more details) initToolGroups(extensionManager, toolGroupService, commandsManager); - toolbarService.init(extensionManager); toolbarService.addButtons(toolbarButtons); toolbarService.createButtonSection('primary', [ 'MeasurementTools', diff --git a/platform/docs/docs/platform/extensions/modules/toolbar.md b/platform/docs/docs/platform/extensions/modules/toolbar.md index bf48fda6c..1af8549c7 100644 --- a/platform/docs/docs/platform/extensions/modules/toolbar.md +++ b/platform/docs/docs/platform/extensions/modules/toolbar.md @@ -6,29 +6,24 @@ sidebar_label: Toolbar # Module: Toolbar An extension can register a Toolbar Module by defining a `getToolbarModule` -method. `OHIF-v3`'s `default` extension (`"@ohif/extension-default"`) provides 5 main -toolbar button types: +method. `OHIF-v3`'s `default` extension (`"@ohif/extension-default"`) provides the +following toolbar button `uiTypes`: -![toolbarModule](../../../assets/img/toolbar-module.png) +- `ohif.radioGroup`: which is a simple button that can be clicked +- `ohif.splitButton`: which is a button with a dropdown menu +- `ohif.divider`: which is a simple divider ## Example Toolbar Module The Toolbar Module should return an array of `objects`. There are currently a few different variations of definitions, each one is detailed further down. +There are two things that the toolbar module can provide, first +a component, and second evaluators. +### Components ```js export default function getToolbarModule({ commandsManager, servicesManager }) { return [ - { - name: 'ohif.divider', - defaultComponent: ToolbarDivider, - clickHandler: () => {}, - }, - { - name: 'ohif.action', - defaultComponent: ToolbarButton, - clickHandler: () => {}, - }, { name: 'ohif.radioGroup', defaultComponent: ToolbarButton, @@ -53,214 +48,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { } ``` -## Toolbar buttons consumed in modes - -Below we can see a simplified version of the `longitudinal` mode that shows how -a mode can add buttons to the toolbar by calling -`ToolBarService.addButtons(toolbarButtons)`. `toolbarButtons` is an array of -`toolDefinitions` which we will learn next. - -```js -function modeFactory({ modeConfiguration }) { - return { - id: 'viewer', - displayName: 'Basic Viewer', - - onModeEnter: ({ servicesManager, extensionManager }) => { - const { ToolBarService } = servicesManager.services; - - ToolBarService.init(extensionManager); - ToolBarService.addButtons(toolbarButtons); - }, - routes: [ - { - path: 'longitudinal', - layoutTemplate: ({ location, servicesManager }) => { - return { - /* */ - }; - }, - }, - ], - }; -} -``` - -## Button Definitions - -The simplest toolbarButtons definition has the following properties: - -![toolbarModule-zoom](../../../assets/img/toolbarModule-zoom.png) - -```js -{ - "id": "Zoom", - "type": "ohif.radioGroup", - "props": { - "type": "tool", - "icon": "tool-zoom", - "label": "Zoom", - "commands": [ - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "Zoom" - }, - "context": "CORNERSTONE" - } - ] - } -} -``` - -| property | description | values | -| ---------------- | ----------------------------------------------------------------- | ------------------------------------------- | -| `id` | Unique string identifier for the definition | \* | -| `type` | Used to determine the button's behaviour | "tool", "toggle", "action" | -| `icon` | A string name for an icon supported by the consuming application. | \* | -| `label` | User/display friendly to show in UI | \* | -| `commands` | (optional) The commands to run when the button is used. It include a commandName, commandOptions, and/or a context | Any command registered by a `CommandModule` | - -There are three main types of toolbar buttons: - -- `tool`: buttons that enable a tool by running the `setToolActive` command with - the `commandOptions` -- `toggle`: buttons that acts as a toggle: e.g., linking viewports -- `action`: buttons that executes an action: e.g., capture button to save - screenshot - -## Nested Buttons - -You can use the `ohif.splitButton` type to build a button with extra tools in -the dropdown. - -- First you need to give your `primary` tool definition to the split button. The primary -tool can specify a `uiType` property which can be one of the button types returned by -`getToolbarModule` that is a variation of `ToolbarButton`. If `uiType` is omitted then -`ToolbarButton` is used by default. -- The `secondary` properties can be a simple arrow down (`chevron-down` icon) -- For adding the extra tools add them to the `items` list. - -You can see below how `longitudinal` mode is using the available toolbarModule -to create `MeasurementTools` nested button - -![toolbarModule-nested-buttons](../../../assets/img/toolbarModule-nested-buttons.png) - -```js title="modes/longitudinal/src/toolbarButtons.js" -{ - "id": "MeasurementTools", - "type": "ohif.splitButton", - "props": { - "groupId": "MeasurementTools", - "isRadio": true, - "primary": { - "id": "Length", - "icon": "tool-length", - "label": "Length", - "type": "tool", - "commands": [ - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "Length" - }, - "context": "CORNERSTONE" - }, - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "SRLength", - "toolGroupId": "SRToolGroup" - }, - "context": "CORNERSTONE" - } - ], - "tooltip": "Length" - }, - "secondary": { - "icon": "chevron-down", - "label": "", - "isActive": true, - "tooltip": "More Measure Tools" - }, - "items": [ - { - "id": "Bidirectional", - "icon": "tool-bidirectional", - "label": "Bidirectional", - "type": "tool", - "commands": [ - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "Bidirectional" - }, - "context": "CORNERSTONE" - }, - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "SRBidirectional", - "toolGroupId": "SRToolGroup" - }, - "context": "CORNERSTONE" - } - ], - "tooltip": "Bidirectional Tool" - }, - { - "id": "ArrowAnnotate", - "icon": "tool-annotate", - "label": "Annotation", - "type": "tool", - "commands": [ - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "ArrowAnnotate" - }, - "context": "CORNERSTONE" - }, - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "SRArrowAnnotate", - "toolGroupId": "SRToolGroup" - }, - "context": "CORNERSTONE" - } - ], - "tooltip": "Arrow Annotate" - }, - ] - } -} -``` - -
- -
- -## Layout Template - -Layout selector button and logic is also provided by the OHIF-v3 `default` -extension. To use it, you can just add the following definition to the list of -`toolDefinitions` - -![toolbarModule-layout](../../../assets/img/toolbarModule-layout.png) - -```js -{ - id: 'Layout', - type: 'ohif.layoutSelector', -} -``` - -
- -
- -## Custom Button +### Custom Components You can also create your own extension, and add your new custom tool appearance (e.g., split horizontally instead of vertically for split tool). Simply add @@ -283,6 +71,323 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { } ``` -## Custom tool +Check out how to assemble the toolbar in the [modes](../../modes/index.md) section. -**I want to create a new tool** + +### Evaluators +Buttons may be equipped with evaluators, which are functions invoked by the toolbarService to assess the button's status. These evaluators are expected to return an object of `{className}` and may include additional details, as elaborated in the subsequent section. + +Evaluators play a crucial role in determining the button's status based on the viewport. For example, users should be restricted from clicking on the mpr if the displaySet is not reconstructable. Additionally, certain buttons within the toolbar may be associated with specific toolGroups and should remain inactive for certain viewports. + +Let's look at one of the evaluators (for `evaluate.cornerstoneTool`) + +```js + { + 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', + }; + }, +}, +``` + +as you can see the job of this evaluator is to determine if the button should be disabled or not. It does so by checking the `toolGroup` and the `toolName` and then returns an object with `disabled` and `className` properties. + +The following evaluators are provided by us: + +- `evaluate.cornerstoneTool`: If assigned to a button (see next), it will make the button react to the active viewport state based on its toolGroup. +- `evaluate.cornerstoneTool.toggle`: It is designed to consider tools with toggle behavior, such as reference lines and image overlay (either on or off). +- `evaluate.cornerstone.synchronizer`: This is designed to consider the synchronizer state of the viewport, whether it is synced or not. +- `evaluate.viewportProperties.toggle`: Some properties of the viewport are toggleable, such as invert, flip, rotate, etc. By assigning this evaluator to those buttons, they will react to the active viewport state based on its properties. This allows for dynamic buttons that change their appearance based on the active viewport state. +- `evaluate.mpr`: special evaluator for MPR since it needs to check if the displaySet is reconstructable or not. + + +Sometime you want to use the same `evaluator` for different purposes, in that case you can use an object +with `name` and `options` properties. For example, in `'evaluate.cornerstone.segmentation'` we use +this pattern, where multiple toolbar buttons are using the same evaluator but with different options ( + in this case `toolNames` +) + +```js +{ + name: 'evaluate.cornerstone.segmentation', + options: { + toolNames: ['CircleBrush' , 'SphereBrush'] + }, +}, +``` + +#### Group evaluators +Split buttons (see in [ToolbarService](../../services/data/ToolbarService.md) on how to define one) may feature a group evaluator, we provide two of them and you can write your own. + + +- `evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList`: determine the outcome of user interactions with the split buttons on what button should be promoted to the primary section. In the example above, the cornerstone tool's status is checked, and if it is not active in the list of buttons, the button is promoted to the primary section. +- `evaluate.group.promoteToPrimary`: disregarding the cornerstone tool's status and promoting the button to the primary section regardless. + +Failure to specify a group evaluator will result in no action, leaving the button in the secondary section. + +:::note +As you have learned so far, the extension modules only 'provides' the functionality +and it is the mode's job to consume it. You can next learn how to consume these components +and evaluators to build a toolbar in the +::: + + +#### Custom Evaluators +You can create your own evaluators. For instance, you have the option to design tri-state buttons, which are buttons with three states such as Show All, Show Some, or Show None of the Viewport Overlays. + + +## Toolbar buttons consumed in modes +Providing just the components is not enough. You need to add the buttons to the toolbar service and decide which ones are used for each section. + +Below we can see a simplified version of the `longitudinal` (basic viewer) mode that shows how +a mode can add buttons to the toolbar by calling +`ToolBarService.addButtons(toolbarButtons)`. `toolbarButtons` is an array of +`toolDefinitions` which we will learn next. + +```js +function modeFactory({ modeConfiguration }) { + return { + id: 'viewer', + displayName: 'Basic Viewer', + + onModeEnter: ({ servicesManager, extensionManager }) => { + const { toolBarService } = servicesManager.services; + + toolbarService.addButtons([...toolbarButtons, ...moreTools]); + toolbarService.createButtonSection('primary', [ + 'MeasurementTools', + 'Zoom', + 'info', + 'WindowLevel', + 'Pan', + 'Capture', + 'Layout', + 'MPR', + 'Crosshairs', + 'MoreTools', + ]); + }, + routes: [ + { + path: 'longitudinal', + layoutTemplate: ({ location, servicesManager }) => { + return { + /* */ + }; + }, + }, + ], + }; +} +``` + + +:::note +By default OHIF's default layout (`extensions/default/src/ViewerLayout/index.tsx`) which is used in all modes use a Toolbar component that creates a +`primary` section for tools. That is why we are creating a `primary` section in the example above. + +Layouts are also customizable, and you can create your own layout in your extensions and provide it to your modes view `getLayoutTemplateModule` module. + +By default we use `@ohif/extension-default.layoutTemplateModule.viewerLayout` to use the default layout which provides a + +- Header (with logo on left, toolbar in the middle and user menu on the right) +- Left panel +- Main viewport grid area +- Right panel +::: + + +## Alternative Toolbar sections + +In your UI component, such as panels, you have the option to include a toolbar section template. +This allows you to easily add buttons to it later on. To ensure that the buttons are added properly +to the toolbar, respond to interactions correctly, and evaluate states accurately, simply utilize the `useToolbar` hook. +This hook grants you access to the `onInteraction` function and the `toolbarButtons` array, which you can customize within your UI as needed. + +```js + +function myCustomPanel({servicesManager}){ + const { onInteraction, toolbarButtons } = useToolbar({ + servicesManager, + buttonSection: 'myCustomSectionName' + }); + + // map the buttons to the UI + return ( +
+ {toolbarButtons.map((button, index) => { + return ( + + ); + })} +
+ ); +} + +``` + +We have provided a common component for toolbar buttons called `Toolbox`. +The Toolbox component serves as a versatile and configurable container for toolbar tools within your application. +It is designed to work in conjunction with the useToolbar hook to manage tool states, handle user interactions, and memorize options +using context API. + + +The `Toolbox` can be easily integrated into your application UI, requiring only the necessary services (servicesManager, commandsManager) and configuration parameters (buttonSectionId, title). Here's a simple usage scenario: + + + +```js +function MyApplication({ servicesManager, commandsManager }) { + // Configuration for the toolbox container + const config = { + servicesManager, + commandsManager, + buttonSectionId: 'customButtonSection', + title: 'My Toolbox', + }; + + return ; +} +``` + +Then in your modes you can edit the tools in that button section. + +```js + +onModeEnter: ({ servicesManager, extensionManager }) => { + const { toolBarService } = servicesManager.services; + + toolbarService.addButtons([...toolbarButtons, ...moreTools]); + toolbarService.createButtonSection('customButtonSection', [ + 'MeasurementTools', + 'Zoom', + 'info', + ]); +}, +``` + +Another example might be you want to open a modal to show some tool options when a button is clicked. +You can use this pattern + + +```js +// ToolbarButton in mode + { + id: 'Others', + uiType: 'ohif.radioGroup', + props: { + icon: 'info-action', + label: 'Others', + commands: 'showOthersModal', + }, + }, +``` + +and inside your mode factory + +```js +// adding the 'Others' button to the primary section +toolbarService.createButtonSection('primary', [ + 'Others', // --------> this one +]); + +// adding the shapes button to the 'Other' section +toolbarService.createButtonSection('other', ['Shapes']); +``` + +here as you see we are using a command `showOthersModal` which is defined in the commands module. + +```js +// inside commandsModule of your extension + showOthersModal: () => { + const { uiModalService } = servicesManager.services; + uiModalService.show({ + content: OthersModal, + title: 'Others', + customClassName: 'w-8', + movable: true, + contentProps: { + onClose: uiModalService.hide, + servicesManager, + commandsManager, + }, + containerDimensions: 'h-[125px] w-[300px]', + contentDimensions: 'h-[125px] w-[300px]', + }); + }, +``` + +as you see it is opening a modal with `OthersModal` component (below) which contains the +`Toolbox` component. + +```js +// Others modal +import { Toolbox } from '@ohif/ui'; + +function OthersModal({ servicesManager, commandsManager }) { + return ( +
+ +
+ ); +} +``` + +The result would be a modal with a toolbox inside it when the `Others` button is clicked, and the +state will get synchronized with the toolbar service automatically. + + +![alt text](../../../assets/img/toolbox-modal.png) + + + +## Change Toolbar with hanging protocols + +If you want to change the toolbar based on the hanging protocol, you can do a pattern like this. + +```js + + const { unsubscribe } = hangingProtocolService.subscribe( + hangingProtocolService.EVENTS.PROTOCOL_CHANGED, + () => { + toolbarService.createButtonSection('primary', [ + 'MeasurementTools', + 'Zoom', + 'WindowLevel', + ]); + } +); +``` diff --git a/platform/docs/docs/platform/managers/_category_.json b/platform/docs/docs/platform/managers/_category_.json index 309407792..f2a2e07a2 100644 --- a/platform/docs/docs/platform/managers/_category_.json +++ b/platform/docs/docs/platform/managers/_category_.json @@ -1,4 +1,4 @@ { "label": "Managers", - "position": 11 + "position": 10 } diff --git a/platform/docs/docs/platform/modes/_category_.json b/platform/docs/docs/platform/modes/_category_.json index 80702b9e5..889d84503 100644 --- a/platform/docs/docs/platform/modes/_category_.json +++ b/platform/docs/docs/platform/modes/_category_.json @@ -1,4 +1,4 @@ { "label": "Modes", - "position": 10 + "position": 12 } diff --git a/platform/docs/docs/platform/modes/index.md b/platform/docs/docs/platform/modes/index.md index 35b046d67..e62c8c931 100644 --- a/platform/docs/docs/platform/modes/index.md +++ b/platform/docs/docs/platform/modes/index.md @@ -362,6 +362,11 @@ function modeFactory() { // exports ``` +### Toolbar + + + + ## Registration Similar to extension registration, `viewer` will look inside the `pluginConfig.json` to diff --git a/platform/docs/docs/platform/modes/lifecycle.md b/platform/docs/docs/platform/modes/lifecycle.md index 587fe1bbd..8c0ec41ce 100644 --- a/platform/docs/docs/platform/modes/lifecycle.md +++ b/platform/docs/docs/platform/modes/lifecycle.md @@ -56,7 +56,6 @@ function modeFactory() { // Init Default and SR ToolGroups initToolGroups(extensionManager, ToolGroupService); - ToolBarService.init(extensionManager); ToolBarService.addButtons(toolbarButtons); ToolBarService.createButtonSection('primary', [ 'MeasurementTools', diff --git a/platform/docs/docs/platform/services/_category_.json b/platform/docs/docs/platform/services/_category_.json index 8b57449b2..9a6c3ae5f 100644 --- a/platform/docs/docs/platform/services/_category_.json +++ b/platform/docs/docs/platform/services/_category_.json @@ -1,4 +1,4 @@ { "label": "Services", - "position": 12 + "position": 11 } diff --git a/platform/docs/docs/platform/services/data/SyncGroupService.md b/platform/docs/docs/platform/services/data/SyncGroupService.md new file mode 100644 index 000000000..835fd5621 --- /dev/null +++ b/platform/docs/docs/platform/services/data/SyncGroupService.md @@ -0,0 +1,147 @@ +--- +sidebar_position: 8 +sidebar_label: SyncGroup Service +--- + +# Sync Group Service + +## Overview + +The `SyncGroupService` is responsible for managing synchronization groups in the OHIF Viewer. Synchronization groups allow multiple viewports to be synchronized based on various criteria, such as camera position, window level, zoom/pan, and image slice position. This service provides a centralized way to create, update, and manage synchronization groups. + +Right now, synchronization groups can be defined in the hanging protocols or manually assigning buttons. + + + + +## API + +- `getSyncCreatorForType(type)`: Returns the synchronizer creator function for the specified type. +- `addSynchronizerType(type, creator)`: Adds a new synchronizer type with a custom creator function. +- `getSynchronizer(id)`: Retrieves a synchronizer by its ID. +- `getSynchronizersOfType(type)`: Retrieves an array of synchronizers of the specified type. +- `addViewportToSyncGroup(viewportId, renderingEngineId, syncGroups)`: Adds a viewport to one or more synchronization groups. +- `destroy()`: Destroys all synchronizers. +- `getSynchronizersForViewport(viewportId)`: Retrieves an array of synchronizers associated with the specified viewport. +- `removeViewportFromSyncGroup(viewportId, renderingEngineId, syncGroupId?)`: Removes a viewport from a specific synchronization group or all synchronization groups if no group ID is provided. + +## Usage +### Via hanging protocols +You can set up different types of synchronization groups for your viewports. For example, in the TMTV hanging protocol (`extensions/tmtv/src/getHangingProtocolModule.js`), we can see how different synchronization groups are defined for various viewports: + +```javascript +const ptAXIAL = { + viewportOptions: { + // ... + syncGroups: [ + { + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: true, + target: false, + options: { + syncInvertState: false, + }, + }, + ], + }, + // ... +}; +``` + + + +In this example, the `ptAXIAL` viewport is part of three synchronization groups: + +1. `cameraPosition` group with the ID `'axialSync'`: This group synchronizes the camera position across viewports that are both source and target. +2. `voi` (Window Level) group with the ID `'ptWLSync'`: This group synchronizes the window level settings across viewports that are both source and target. +3. `voi` group with the ID `'ptFusionWLSync'`: This group synchronizes the window level settings, but the `ptAXIAL` viewport is only a source, not a target. + + +:::tip +You can control the state of the synchronizer via a toolbar button after you define the synchronization group in the hanging protocol. + +```js +{ + id: 'SyncToggle', + uiType: 'ohif.radioGroup', + props: { + icon: 'tool-info', + label: 'toggle', + commands: { + commandName: 'toggleSynchronizer', + commandOptions: { + syncId: 'axialSync' + } + } + }, +}, +``` + +as you can see by using the `toggleSynchronizer` command you can toggle the state of the synchronizer for the specified syncId. + +::: + +### Manually through a button +You can create a button on the toolbar that you provice the synchronization group type, +and it applys it to all viewports. + +:::note +Currently we don't have a proper way to select viewports to apply the synchronization group to. It is applied to all applicable viewports +::: + +For instance look at `imageSliceSync` button in the longitudinal mode (`modes/longitudinal/src/moreTools.ts`) and how it runs a command + +```js +ToolbarService.createButton({ + id: 'ImageSliceSync', + icon: 'link', + label: 'Image Slice Sync', + tooltip: 'Enable position synchronization on stack viewports', + commands: [ + { + commandName: 'toggleSynchronizer', + commandOptions: { + type: 'imageSlice', + }, + }, + ], +}) +``` + +You can create another button to toggle 'voi' synchronization. Currently we group +viewports by modality and apply the voi synchronization to all viewports of the same modality. + +```js +ToolbarService.createButton({ + id: 'VoiSync', + icon: 'link', + label: 'VOI Sync', + tooltip: 'Enable VOI synchronization on viewports', + commands: [ + { + commandName: 'toggleSynchronizer', + commandOptions: { + type: 'voi', + }, + }, + ], +}) +``` + +:::tip +For your custom synchronization groups, you can create a new synchronizer type and follow the +same pattern as the existing synchronizers. +::: diff --git a/platform/docs/docs/platform/services/data/ToolGroupService.md b/platform/docs/docs/platform/services/data/ToolGroupService.md new file mode 100644 index 000000000..0d7a04cf0 --- /dev/null +++ b/platform/docs/docs/platform/services/data/ToolGroupService.md @@ -0,0 +1,89 @@ +--- +sidebar_position: 7 +sidebar_label: ToolGroup Service +--- + +# Tool Group Service + +## Overview + +The `ToolGroupService` is responsible for managing tool groups in the OHIF Viewer. + +:::tip +Read more about toolGroups [here](https://www.cornerstonejs.org/docs/concepts/cornerstone-tools/toolGroups) +::: + +It allows you to create, update, and manage tool groups and the tools associated with them. Tool groups are used to organize and control the behavior of various tools in the viewer, such as window level, pan, zoom, measurements, and annotations. + +## Events + +The `ToolGroupService` emits the following events: + +| Event | Description | +| ---------------------------------- | ----------------------------------------------- | +| `VIEWPORT_ADDED` | Fires when a viewport is added to a tool group | +| `TOOLGROUP_CREATED` | Fires when a new tool group is created | + +## API + +- `getToolGroup(toolGroupId?)`: Retrieves a tool group by its ID. If no ID is provided, it returns the tool group for the active viewport. +- `getToolGroupIds()`: Returns an array of all tool group IDs. +- `getToolGroupForViewport(viewportId)`: Returns the tool group associated with the specified viewport. +- `getActiveToolForViewport(viewportId)`: Returns the active tool for the specified viewport. +- `destroy()`: Destroys all tool groups. +- `destroyToolGroup(toolGroupId)`: Destroys the specified tool group. +- `removeViewportFromToolGroup(viewportId, renderingEngineId, deleteToolGroupIfEmpty?)`: Removes a viewport from a tool group. If `deleteToolGroupIfEmpty` is true and the tool group becomes empty after removing the viewport, it will be destroyed. +- `addViewportToToolGroup(viewportId, renderingEngineId, toolGroupId?)`: Adds a viewport to a tool group. If `toolGroupId` is not provided, the viewport will be added to all tool groups. +- `createToolGroup(toolGroupId)`: Creates a new tool group with the specified ID. +- `addToolsToToolGroup(toolGroupId, tools, configs?)`: Adds tools to the specified tool group with optional configurations. +- `createToolGroupAndAddTools(toolGroupId, tools)`: Creates a new tool group and adds the specified tools to it. +- `getToolConfiguration(toolGroupId, toolName)`: Retrieves the configuration for the specified tool in the given tool group. +- `setToolConfiguration(toolGroupId, toolName, config)`: Sets the configuration for the specified tool in the given tool group. + +## Usage + +Here's an example of how to create a new tool group and add tools to it in our basic viewer mode (modes/longitudinal/src/initToolGroups.js) + +```js +import { initToolGroups } from '@ohif/extension-cornerstone'; +import { ToolGroupService } from '@ohif/core'; + +const toolGroupService = new ToolGroupService(); + +// Create a new tool group +const defaultToolGroup = toolGroupService.createToolGroup('default'); + +// Define tools for the tool group +const tools = { + active: [ + { toolName: 'WindowLevel', bindings: [{ mouseButton: 1 }] }, + { toolName: 'Pan', bindings: [{ mouseButton: 2 }] }, + { toolName: 'Zoom', bindings: [{ mouseButton: 3 }] }, + ], + passive: [ + { toolName: 'Length' }, + { toolName: 'ArrowAnnotate' }, + { toolName: 'Bidirectional' }, + ], +}; + +// Add tools to the tool group +toolGroupService.addToolsToToolGroup('default', tools); +``` + +In this example, we create a new `ToolGroupService` instance and use it to create a new tool group with the ID `'default'`. We then define an object `tools` that contains the active and passive tools we want to add to the tool group. Finally, we call the `addToolsToToolGroup` method to add the tools to the newly created tool group. + +:::tip +You can begin the viewer with certain toggle tools already active. For example, if you have your 'referencelines' tool enabled, it will be active when the viewer starts, and the icon state will be correctly set to active as well. +::: + +```js +const tools = { + // the reset + // enabled + enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }], + }; +``` + + +![alt text](../../../assets/img/reference-lines-from-start.png) diff --git a/platform/docs/docs/platform/services/data/ToolbarService.md b/platform/docs/docs/platform/services/data/ToolbarService.md index 3acfb3e2d..5a0a15964 100644 --- a/platform/docs/docs/platform/services/data/ToolbarService.md +++ b/platform/docs/docs/platform/services/data/ToolbarService.md @@ -3,16 +3,15 @@ sidebar_position: 5 sidebar_label: Toolbar Service --- -# Toolbar Service +# Toolbar **Service** ## Overview -`ToolBarService` handles the toolbar section buttons, and what happens when a -button is clicked by the user. +The `ToolBarService` is a straightforward service designed to handle the toolbar. Its main tasks include adding buttons, configuring them, and organizing button sections. When a button is clicked, it executes the designated commands. In the past, this service was more intricate, managing button states and logic. However, all that functionality has now been transferred to the `ToolBarModule` and evaluators. -
- -
+ ## Events @@ -23,20 +22,7 @@ button is clicked by the user. ## API -- `recordInteraction(interaction)`: executes the provided interaction which is - an object providing the following properties to the ToolBarService: - - - `interactionType`: can be `tool`, `toggle` and `action`. We will discuss - more each type below. - - `itemId`: tool name - - `groupId`: the Id for the tool button group; e.g., `Wwwc` which holds - presets. - - `commandName`: if tool has a command attached to run - - `commandOptions`: arguments for the command. - - `setActive`: Sets a given tool active (not as primary but as secondary) - -- `reset`: reset the state of the toolbarService, set the primary tool to be - `Wwwc` and unsubscribe tools that have registered their functions. +- `createButtonSection(key, buttons)` : creates a section of buttons in the toolbar with the given key and button Ids - `addButtons`: add the button definition to the service. [See below for button definition](#button-definitions). @@ -44,86 +30,26 @@ button is clicked by the user. - `setButtons`: sets the buttons defined in the service. It overrides all the previous buttons -- `getActiveTools`: returns the active tool + all the toggled-on tools -- `setDefaultTool`: sets the default tool that will be activated whenever the primary tool is deactivated without activating another/different tool -## State - -ToolBarService has an internal state that gets updated per tool interaction and -tracks the active toolId, state of the buttons that have toggled state, and the -group buttons and which tool in each group is active. - -```js -state = { - primaryToolId: 'Wwwc', - toggles: { - /* id: true/false */ - }, - groups: { - /* track most recent click per group...*/ - }, -}; -``` - -## Interaction type - -There are three main types that a tool can have which is defined in the -interaction object. - -- `tool`: setting a tool to be active; e.g., measurement tools -- `toggle`: toggling state of a tool; e.g., viewport link (sync) -- `action`: performs a registered action outside of the ToolBarService; e.g., - capture - -A _simplified_ implementation of the ToolBarService is: - -```js -export default class ToolBarService { - /** ... **/ - recordInteraction(interaction) { - /** ... **/ - switch (interactionType) { - case 'action': { - break; - } - case 'tool': { - this.state.primaryToolId = itemId; - - commandsManager.runCommand('setToolActive', interaction.commandOptions); - break; - } - case 'toggle': { - this.state.toggles[itemId] = - this.state.toggles[itemId] === undefined - ? true - : !this.state.toggles[itemId]; - interaction.commandOptions.toggledState = this.state.toggles[itemId]; - break; - } - default: - throw new Error(`Invalid interaction type: ${interactionType}`); - } - /** ... **/ - } - /** ... **/ -} -``` ## Button Definitions + +### Basic + The simplest toolbarButtons definition has the following properties: ![toolbarModule-zoom](../../../assets/img/toolbarModule-zoom.png) + ```js { - "id": "Zoom", - "type": "ohif.radioGroup", - "props": { - "type": "tool", - "icon": "tool-zoom", - "label": "Zoom", + id: 'Zoom', + uiType: 'ohif.radioGroup', + props: { + icon: 'tool-zoom', + label: 'Zoom', "commands": [ { "commandName": "setToolActive", @@ -133,27 +59,20 @@ The simplest toolbarButtons definition has the following properties: "context": "CORNERSTONE" } ] - } -} + evaluate: 'evaluate.cornerstoneTool', + }, +}, ``` | property | description | values | | ---------------- | ----------------------------------------------------------------- | ------------------------------------------- | | `id` | Unique string identifier for the definition | \* | -| `type` | Used to determine the button's behaviour | "tool", "toggle", "action" | | `icon` | A string name for an icon supported by the consuming application. | \* | | `label` | User/display friendly to show in UI | \* | | `commands` | (optional) The commands to run when the button is used. It include a commandName, commandOptions, and/or a context | Any command registered by a `CommandModule` | -There are three main types of toolbar buttons: -- `tool`: buttons that enable a tool by running the `setToolActive` command with - the `commandOptions` -- `toggle`: buttons that acts as a toggle: e.g., linking viewports -- `action`: buttons that executes an action: e.g., capture button to save - screenshot - -## Nested Buttons +### Nested (dropdown) You can use the `ohif.splitButton` type to build a button with extra tools in the dropdown. @@ -169,91 +88,178 @@ to create `MeasurementTools` nested button ```js title="modes/longitudinal/src/toolbarButtons.js" { - "id": "MeasurementTools", - "type": "ohif.splitButton", - "props": { - "groupId": "MeasurementTools", - "isRadio": true, - "primary": { - "id": "Length", - "icon": "tool-length", - "label": "Length", - "type": "tool", - "commands": [ - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "Length" - }, - "context": "CORNERSTONE" - }, - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "SRLength", - "toolGroupId": "SRToolGroup" - }, - "context": "CORNERSTONE" - } + id: 'MeasurementTools', + uiType: 'ohif.splitButton', + props: { + groupId: 'MeasurementToolsGroupId', + // group evaluate to determine which item should move to the top + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: ToolbarService.createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: _createSetToolActiveCommands('Length'), + evaluate: 'evaluate.cornerstoneTool', + }), + secondary: { + icon: 'chevron-down', + tooltip: 'More Measure Tools', + }, + items: [ + ToolbarService.createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'Length', + }, + context: 'CORNERSTONE', + }, + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'SRLength', + toolGroupId: 'SRToolGroup', + }, + // we can use the setToolActive command for this from Cornerstone commandsModule + context: 'CORNERSTONE', + }, + ], + evaluate: 'evaluate.cornerstoneTool', + }), + ToolbarService.createButton({ + id: 'Bidirectional', + icon: 'tool-bidirectional', + label: 'Bidirectional', + tooltip: 'Bidirectional Tool', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'Bidirectional', + }, + context: 'CORNERSTONE', + }, + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'SRBidirectional', + toolGroupId: 'SRToolGroup', + }, + context: 'CORNERSTONE', + }, + ], + evaluate: 'evaluate.cornerstoneTool', + }), ], - "tooltip": "Length" }, - "secondary": { - "icon": "chevron-down", - "label": "", - "isActive": true, - "tooltip": "More Measure Tools" - }, - "items": [ - { - "id": "Bidirectional", - "icon": "tool-bidirectional", - "label": "Bidirectional", - "type": "tool", - "commands": [ - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "Bidirectional" - }, - "context": "CORNERSTONE" - }, - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "SRBidirectional", - "toolGroupId": "SRToolGroup" - }, - "context": "CORNERSTONE" - } - ], - "tooltip": "Bidirectional Tool" - }, - { - "id": "ArrowAnnotate", - "icon": "tool-annotate", - "label": "Annotation", - "type": "tool", - "commands": [ - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "ArrowAnnotate" - }, - "context": "CORNERSTONE" - }, - { - "commandName": "setToolActive", - "commandOptions": { - "toolName": "SRArrowAnnotate", - "toolGroupId": "SRToolGroup" - }, - "context": "CORNERSTONE" - } - ], - "tooltip": "Arrow Annotate" - }, - ] - } -} + }, ``` + +:::tip +split buttons can have a group evaluator (in the above example `evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList`) which can decide what happens +when the user interacts with the buttons. In the above example, we are promoting the button to the primary section if the cornerstone tool is not active in the list of buttons. + +There are other evaluators for instance `evaluate.group.promoteToPrimary` +which does not care about the cornerstone tool and promotes the button to the primary section anyway +::: + +:::tip +If you don't provide a group evaluator nothing would happen and the button will stay in the secondary section. +::: + +## Listeners +Sometimes you need a tool to listen to specific events in order to react properly. +You can add `listeners` for this purpose. We use this pattern for referencelineTools +which should set its source of reference upon active viewport change + +Currently you can subscribe to the following events: +- `ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED`: when the active viewport changes +- `ViewportGridService.EVENTS.VIEWPORTS_READY`: when the viewports are ready in the grid + + +```js + +const ReferenceLinesListeners: RunCommand = [ + { + commandName: 'setSourceViewportForReferenceLinesTool', + context: 'CORNERSTONE', + }, +]; + +ToolbarService.createButton({ + id: 'ReferenceLines', + icon: 'tool-referenceLines', + label: 'Reference Lines', + tooltip: 'Show Reference Lines', + commands: [ + { + commandName: 'setToolEnabled', + commandOptions: { + toolName: 'ReferenceLines', + toggle: true, + }, + context: 'CORNERSTONE', + }, + ], + listeners: { + [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners, + [ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners, + }, + evaluate: 'evaluate.cornerstoneTool.toggle', +}), + +``` + + + +## Button Sections +In order to organize the buttons, you can create button sections in the toolbar. And +assign buttons to each section separately. + +OHIF provides a `primary` section by default. You can add more sections as needed in your UI +and use toolbarService to create and manage them. (You can look at the toolBox implementation +which take advantage of having a dedicated section for the tools with advanced options, +we use that in the segmentation mode). + + +## Example + +For instance in `longitudinal` mode we are using the `onModeEnter` hook to +add the buttons to the toolbarService and assign them to the primary section. + +```js title="modes/longitudinal/src/index.js" +toolbarService.addButtons([...toolbarButtons, ...moreTools]); +toolbarService.createButtonSection('primary', [ + 'MeasurementTools', + 'Zoom', + 'info', + 'WindowLevel', + 'Pan', + 'Capture', + 'Layout', + 'MPR', + 'Crosshairs', + 'MoreTools', +]); +``` + +as you see we creating the button section and assigning buttons based on their Ids. + +:::tip +You can even duplicate the same button in different sections and the button will be +in sync in all sections (thanks to the evaluation system). +::: + +:::tip +we will add more section in the toolbar (other than primary) in the future. +::: + +:::note +Don't forget to set up your toolGroups to ensure that your buttons function correctly. Buttons serve as a visual interface. When you interact with them, they execute their commands, and evaluators determine their state post-interaction. +::: diff --git a/platform/docs/docs/platform/services/ui/viewport-grid-service.md b/platform/docs/docs/platform/services/ui/viewport-grid-service.md index 6f8435905..186afc419 100644 --- a/platform/docs/docs/platform/services/ui/viewport-grid-service.md +++ b/platform/docs/docs/platform/services/ui/viewport-grid-service.md @@ -18,6 +18,8 @@ There are seven events that get publish in `ViewportGridService `: | ACTIVE_VIEWPORT_ID_CHANGED | Fires the Id of the active viewport is changed | | LAYOUT_CHANGED | Fires the layout is changed | | GRID_STATE_CHANGED | Fires when the entire grid state is changed | +| VIEWPORTS_READY | Fires when the viewports are ready in the grid | + ## Interface For a more detailed look on the options and return values each of these methods diff --git a/platform/ui/.storybook/main.ts b/platform/ui/.storybook/main.ts index 54b817b1a..62612396a 100644 --- a/platform/ui/.storybook/main.ts +++ b/platform/ui/.storybook/main.ts @@ -85,6 +85,14 @@ const config: StorybookConfig = { include: path.resolve(__dirname, '../'), }); + // ignore the file @icr/polyseg-wasm during the build as it is a wasm file and + // we don't need that for ui + config.module.rules.push({ + test: /@icr\/polyseg-wasm/, + type: 'javascript/auto', + loader: 'file-loader', + }); + // Return the altered config return config; }, diff --git a/platform/ui/src/assets/icons/icon-tool-brush.svg b/platform/ui/src/assets/icons/icon-tool-brush.svg index 7f78d720a..2258260d9 100644 --- a/platform/ui/src/assets/icons/icon-tool-brush.svg +++ b/platform/ui/src/assets/icons/icon-tool-brush.svg @@ -1,13 +1,9 @@ - - icon-tool-brush - - - - - - - - + + tool-seg-brush + + + + \ No newline at end of file diff --git a/platform/ui/src/assets/icons/icon-tool-eraser.svg b/platform/ui/src/assets/icons/icon-tool-eraser.svg index 5c72e4c37..a1f78fed4 100644 --- a/platform/ui/src/assets/icons/icon-tool-eraser.svg +++ b/platform/ui/src/assets/icons/icon-tool-eraser.svg @@ -1,14 +1,10 @@ - - icon-tool-eraser - - - - - - - - - + + tool-seg-eraser + + + + + \ No newline at end of file diff --git a/platform/ui/src/assets/icons/icon-tool-shape.svg b/platform/ui/src/assets/icons/icon-tool-shape.svg index 132f7b58a..290d156e6 100644 --- a/platform/ui/src/assets/icons/icon-tool-shape.svg +++ b/platform/ui/src/assets/icons/icon-tool-shape.svg @@ -1,13 +1,9 @@ - - icon-tool-shape - - - - - - - - + + tool-seg-shape + + + + \ No newline at end of file diff --git a/platform/ui/src/assets/icons/icon-tool-threshold.svg b/platform/ui/src/assets/icons/icon-tool-threshold.svg index d1bcaa76e..4870fa5a4 100644 --- a/platform/ui/src/assets/icons/icon-tool-threshold.svg +++ b/platform/ui/src/assets/icons/icon-tool-threshold.svg @@ -1,17 +1,17 @@ - - icon-tool-threshold - - - - - - - - - - - + + tool-seg-threshold + + + + + + + + + + + \ No newline at end of file diff --git a/platform/ui/src/assets/icons/tool-elipse.svg b/platform/ui/src/assets/icons/tool-ellipse.svg similarity index 100% rename from platform/ui/src/assets/icons/tool-elipse.svg rename to platform/ui/src/assets/icons/tool-ellipse.svg diff --git a/platform/ui/src/components/AdvancedToolbox/AdvancedToolbox.tsx b/platform/ui/src/components/AdvancedToolbox/AdvancedToolbox.tsx index 690ce3570..2748e522b 100644 --- a/platform/ui/src/components/AdvancedToolbox/AdvancedToolbox.tsx +++ b/platform/ui/src/components/AdvancedToolbox/AdvancedToolbox.tsx @@ -3,6 +3,10 @@ import classnames from 'classnames'; import { PanelSection, Icon, Tooltip } from '../../components'; import ToolSettings from './ToolSettings'; +/** + * Use Toolbox component instead of this although it doesn't have "Advanced" in its name + * it is better to use it instead of this one + */ const AdvancedToolbox = ({ title, items }) => { const [activeItemName, setActiveItemName] = useState(null); diff --git a/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx b/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx index 75eae9129..fc587157e 100644 --- a/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx +++ b/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { ButtonGroup, InputRange, ButtonEnums } from '../../components'; +import { ButtonGroup, InputDoubleRange, InputRange } from '../../components'; const SETTING_TYPES = { RANGE: 'range', RADIO: 'radio', CUSTOM: 'custom', + DOUBLE_RANGE: 'double-range', }; function ToolSettings({ options }) { @@ -12,73 +13,111 @@ function ToolSettings({ options }) { return null; } - const getButtons = option => { - const buttons = []; - - option.values?.map(({ label, value: optionValue }, index) => { - buttons.push({ - children: label, - onClick: () => option.onChange(optionValue), - key: `button-${option.id}-${index}`, // A unique key - }); - }); - - return buttons; - }; - return (
{options?.map(option => { - if (option.type === SETTING_TYPES.RANGE) { - return ( -
-
{option.name}
-
- option.onChange(e)} - allowNumberEdit={true} - showAdjustmentArrows={false} - inputClassName="ml-1 w-4/5 cursor-pointer" - /> -
-
- ); + if (option.condition && option.condition?.({ options }) === false) { + return null; } - if (option.type === SETTING_TYPES.RADIO) { - return ( -
- {option.name} -
- -
-
- ); - } - if (option.type === SETTING_TYPES.CUSTOM) { - return ( -
- {typeof option.children === 'function' ? option.children() : option.children} -
- ); + switch (option.type) { + case SETTING_TYPES.RANGE: + return renderRangeSetting(option); + case SETTING_TYPES.RADIO: + return renderRadioSetting(option); + case SETTING_TYPES.DOUBLE_RANGE: + return renderDoubleRangeSetting(option); + case SETTING_TYPES.CUSTOM: + return renderCustomSetting(option); + default: + return null; } })}
); } +const renderRangeSetting = option => { + return ( +
+
{option.name}
+
+ option.commands?.(value)} + allowNumberEdit={true} + showAdjustmentArrows={false} + inputClassName="ml-1 w-4/5 cursor-pointer" + /> +
+
+ ); +}; + +const renderRadioSetting = option => { + const renderButtons = option => { + return option.values?.map(({ label, value: optionValue }, index) => ( + + )); + }; + + return ( +
+ {option.name} +
+ value === option.value) || 0} + > + {renderButtons(option)} + +
+
+ ); +}; + +const renderDoubleRangeSetting = option => { + return ( +
+ +
+ ); +}; + +const renderCustomSetting = option => { + return ( +
+ {typeof option.children === 'function' ? option.children() : option.children} +
+ ); +}; + export default ToolSettings; diff --git a/platform/ui/src/components/AdvancedToolbox/index.js b/platform/ui/src/components/AdvancedToolbox/index.js index 5adfdbc9a..450d4f541 100644 --- a/platform/ui/src/components/AdvancedToolbox/index.js +++ b/platform/ui/src/components/AdvancedToolbox/index.js @@ -1,2 +1,4 @@ import AdvancedToolbox from './AdvancedToolbox'; +import ToolSettings from './ToolSettings'; export default AdvancedToolbox; +export { ToolSettings }; diff --git a/platform/ui/src/components/Button/Button.tsx b/platform/ui/src/components/Button/Button.tsx index b1e750c5c..788eb4a50 100644 --- a/platform/ui/src/components/Button/Button.tsx +++ b/platform/ui/src/components/Button/Button.tsx @@ -67,9 +67,12 @@ const Button = ({ name, className, onClick, + dataCY, startIconTooltip = null, endIconTooltip = null, }) => { + dataCY = dataCY || `${name}-btn`; + const startIcon = startIconProp && ( <> {React.cloneElement(startIconProp, { @@ -109,7 +112,7 @@ const Button = ({ disabled={disabled} ref={buttonElement} onClick={handleOnClick} - data-cy={`${name}-btn`} + data-cy={dataCY} > {startIconTooltip ? {startIcon} : startIcon} {children} @@ -148,6 +151,8 @@ Button.propTypes = { startIconTooltip: PropTypes.node, /** Tooltip for the end icon */ endIconTooltip: PropTypes.node, + /** Data attribute for testing */ + dataCY: PropTypes.string, }; export default Button; diff --git a/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx b/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx index 4d56ddec5..d47288bf2 100644 --- a/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx +++ b/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx @@ -1,21 +1,25 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, cloneElement, Children } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { ButtonEnums } from '../../components'; const ButtonGroup = ({ - buttons, - onActiveIndexChange, + children, className, orientation = ButtonEnums.orientation.horizontal, - defaultActiveIndex = 0, + activeIndex: defaultActiveIndex = 0, + onActiveIndexChange, + disabled = false, }) => { const [activeIndex, setActiveIndex] = useState(defaultActiveIndex); - const handleButtonClick = (e, index) => { + useEffect(() => { + setActiveIndex(defaultActiveIndex); + }, [defaultActiveIndex]); + + const handleButtonClick = index => { setActiveIndex(index); onActiveIndexChange && onActiveIndexChange(index); - buttons[index].onClick && buttons[index].onClick(e); }; const orientationClasses = { @@ -29,35 +33,40 @@ const ButtonGroup = ({
- {buttons.map((buttonProps, index) => { - const isActive = index === activeIndex; - return ( -
); }; ButtonGroup.propTypes = { - buttons: PropTypes.arrayOf(PropTypes.object).isRequired, + children: PropTypes.node.isRequired, orientation: PropTypes.oneOf(Object.values(ButtonEnums.orientation)), - type: PropTypes.oneOf(Object.values(ButtonEnums.type)), - size: PropTypes.oneOf(Object.values(ButtonEnums.size)), - defaultActiveIndex: PropTypes.number, + activeIndex: PropTypes.number, onActiveIndexChange: PropTypes.func, className: PropTypes.string, + disabled: PropTypes.bool, }; export default ButtonGroup; diff --git a/platform/ui/src/components/ButtonGroup/__stories__/buttonGroup.stories.mdx b/platform/ui/src/components/ButtonGroup/__stories__/buttonGroup.stories.mdx index aa15fb5e1..cb5f00ddf 100644 --- a/platform/ui/src/components/ButtonGroup/__stories__/buttonGroup.stories.mdx +++ b/platform/ui/src/components/ButtonGroup/__stories__/buttonGroup.stories.mdx @@ -16,10 +16,6 @@ export const argTypes = { component={ButtonGroup} /> -export const buttonGroupTemplate = args => { - return ; -}; - { ButtonGroup is a container for a group of buttons. - console.log('Button 1 clicked'), - }, - { - children: 'Button 2', - onClick: () => console.log('Button 2 clicked'), - }, - { - children: 'Button 3', - onClick: () => console.log('Button 3 clicked'), - }, - ], - activeIndex: 0, - onActiveIndexChange: index => console.log(index), - }} - > - {buttonGroupTemplate.bind({})} + + console.log(index)} + > + + + + diff --git a/platform/ui/src/components/Header/Header.tsx b/platform/ui/src/components/Header/Header.tsx index 18fdbabc4..99b97b527 100644 --- a/platform/ui/src/components/Header/Header.tsx +++ b/platform/ui/src/components/Header/Header.tsx @@ -30,13 +30,11 @@ function Header({ return ( -
-
- {/* // TODO: Should preserve filter/sort - // Either injected service? Or context (like react router's `useLocation`?) */} +
+
-
{children}
-
+ {/*
{future left component}
*/} +
+
{children}
+
+
{ const buttonElement = useRef(null); @@ -118,7 +92,7 @@ const IconButton = ({ onClick(e); }; - const bgColorToUse = bgColor ? bgColor : backgroundClasses[variant][color]; + const padding = size === 'toolbar' ? '8px' : size === 'toolbox' ? '4px' : null; return (