From 949a156798ee025d0b045acaf7422f4797e159d2 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 3 Oct 2023 10:59:09 -0400 Subject: [PATCH] fix(StackSync): Miscellaneous fixes for stack image sync (#3663) --- .webpack/webpack.base.js | 36 +++-- extensions/cornerstone/src/commandsModule.ts | 20 ++- extensions/cornerstone/src/init.tsx | 78 ++++++---- .../utils/CornerstoneViewportDownloadForm.tsx | 16 ++- .../utils/stackSync/toggleStackImageSync.ts | 136 ++++++++---------- extensions/default/src/commandsModule.ts | 2 +- .../default/src/getDisplaySetMessages.ts | 2 +- modes/basic-test-mode/package.json | 2 +- .../src/{index.js => index.ts} | 6 +- .../{initToolGroups.js => initToolGroups.ts} | 0 .../{toolbarButtons.js => toolbarButtons.ts} | 93 ++++++------ modes/longitudinal/src/index.js | 6 +- .../{toolbarButtons.js => toolbarButtons.ts} | 95 ++++++------ platform/app/.webpack/webpack.pwa.js | 2 + .../app/.webpack/writePluginImportsFile.js | 2 +- .../OHIFCornerstoneToolbar.spec.js | 46 ++++-- .../OHIFDownloadSnapshotFile.spec.js | 2 + .../OHIFGeneralViewer.spec.js | 14 +- .../OHIFStudyBrowser.spec.js | 2 +- platform/app/cypress/support/commands.js | 23 +-- platform/app/public/config/e2e.js | 24 +++- platform/app/src/routes/Mode/Mode.tsx | 2 +- .../services/ToolBarService/ToolbarService.ts | 95 ++++++++++-- platform/core/src/types/Command.ts | 4 +- .../LayoutSelector/LayoutSelector.tsx | 1 + testdata | 2 +- 26 files changed, 425 insertions(+), 286 deletions(-) rename modes/basic-test-mode/src/{index.js => index.ts} (98%) rename modes/basic-test-mode/src/{initToolGroups.js => initToolGroups.ts} (100%) rename modes/basic-test-mode/src/{toolbarButtons.js => toolbarButtons.ts} (91%) rename modes/longitudinal/src/{toolbarButtons.js => toolbarButtons.ts} (90%) diff --git a/.webpack/webpack.base.js b/.webpack/webpack.base.js index 88a009c217..985690bd32 100644 --- a/.webpack/webpack.base.js +++ b/.webpack/webpack.base.js @@ -32,6 +32,26 @@ const COMMIT_HASH = fs.readFileSync(path.join(__dirname, '../commit.txt'), 'utf8 // dotenv.config(); +const defineValues = { + /* Application */ + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + 'process.env.DEBUG': JSON.stringify(process.env.DEBUG), + 'process.env.PUBLIC_URL': JSON.stringify(process.env.PUBLIC_URL || '/'), + 'process.env.BUILD_NUM': JSON.stringify(BUILD_NUM), + 'process.env.VERSION_NUMBER': JSON.stringify(VERSION_NUMBER), + 'process.env.COMMIT_HASH': JSON.stringify(COMMIT_HASH), + /* i18n */ + 'process.env.USE_LOCIZE': JSON.stringify(process.env.USE_LOCIZE || ''), + 'process.env.LOCIZE_PROJECTID': JSON.stringify(process.env.LOCIZE_PROJECTID || ''), + 'process.env.LOCIZE_API_KEY': JSON.stringify(process.env.LOCIZE_API_KEY || ''), + 'process.env.REACT_APP_I18N_DEBUG': JSON.stringify(process.env.REACT_APP_I18N_DEBUG || ''), +}; + +// Only redefine updated values. This avoids warning messages in the logs +if (!process.env.APP_CONFIG) { + defineValues['process.env.APP_CONFIG'] = ''; +} + module.exports = (env, argv, { SRC_DIR, ENTRY }) => { if (!process.env.NODE_ENV) { throw new Error('process.env.NODE_ENV not set'); @@ -133,21 +153,7 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => { }, }, plugins: [ - new webpack.DefinePlugin({ - /* Application */ - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), - 'process.env.DEBUG': JSON.stringify(process.env.DEBUG), - 'process.env.APP_CONFIG': JSON.stringify(process.env.APP_CONFIG || ''), - 'process.env.PUBLIC_URL': JSON.stringify(process.env.PUBLIC_URL || '/'), - 'process.env.BUILD_NUM': JSON.stringify(BUILD_NUM), - 'process.env.VERSION_NUMBER': JSON.stringify(VERSION_NUMBER), - 'process.env.COMMIT_HASH': JSON.stringify(COMMIT_HASH), - /* i18n */ - 'process.env.USE_LOCIZE': JSON.stringify(process.env.USE_LOCIZE || ''), - 'process.env.LOCIZE_PROJECTID': JSON.stringify(process.env.LOCIZE_PROJECTID || ''), - 'process.env.LOCIZE_API_KEY': JSON.stringify(process.env.LOCIZE_API_KEY || ''), - 'process.env.REACT_APP_I18N_DEBUG': JSON.stringify(process.env.REACT_APP_I18N_DEBUG || ''), - }), + new webpack.DefinePlugin(defineValues), new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], }), diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index a5b12709f9..c051cae884 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -265,6 +265,11 @@ function commandsModule({ 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); @@ -549,16 +554,16 @@ function commandsModule({ toggleStackImageSync: ({ toggledState }) => { toggleStackImageSync({ - getEnabledElement, servicesManager, toggledState, }); }, - setSourceViewportForReferenceLinesTool: ({ toggledState }) => { - const { activeViewportId } = viewportGridService.getState(); - const viewportInfo = cornerstoneViewportService.getViewportInfo(activeViewportId); + setSourceViewportForReferenceLinesTool: ({ toggledState, viewportId }) => { + if (!viewportId) { + const { activeViewportId } = viewportGridService.getState(); + viewportId = activeViewportId; + } - const viewportId = viewportInfo.getViewportId(); const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); toolGroup.setToolConfiguration( @@ -699,8 +704,9 @@ function commandsModule({ }, storePresentation: { commandFn: actions.storePresentation, - storeContexts: [], - options: {}, + }, + setToolbarToggled: { + commandFn: actions.setToolbarToggled, }, }; diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 5aaffd23ca..2c6169a383 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -14,7 +14,7 @@ import { utilities as csUtilities, Enums as csEnums, } from '@cornerstonejs/core'; -import { Enums, utilities, ReferenceLinesTool } from '@cornerstonejs/tools'; +import { Enums } from '@cornerstonejs/tools'; import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader'; import initWADOImageLoader from './initWADOImageLoader'; @@ -93,6 +93,7 @@ export default async function init({ cornerstoneViewportService, hangingProtocolService, toolGroupService, + toolbarService, viewportGridService, stateSyncService, } = servicesManager.services as CornerstoneServices; @@ -208,9 +209,45 @@ export default async function init({ commandsManager, }); - const newStackCallback = evt => { + /** + * 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; - utilities.stackPrefetch.enable(element); + 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 }); + }); }; const resetCrosshairs = evt => { @@ -237,11 +274,16 @@ export default async function init({ } }; + eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, evt => { + const { element } = evt.detail; + cornerstoneTools.utilities.stackContextPrefetch.enable(element); + }); + function elementEnabledHandler(evt) { const { element } = evt.detail; element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs); - eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, newStackCallback); + eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, toolbarEventListener); } function elementDisabledHandler(evt) { @@ -262,33 +304,7 @@ export default async function init({ viewportGridService.subscribe( viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, - ({ viewportId }) => { - const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); - - if (!toolGroup || !toolGroup._toolInstances?.['ReferenceLines']) { - return; - } - - // check if reference lines are active - const referenceLinesEnabled = - toolGroup._toolInstances['ReferenceLines'].mode === Enums.ToolModes.Enabled; - - if (!referenceLinesEnabled) { - return; - } - - toolGroup.setToolConfiguration( - ReferenceLinesTool.toolName, - { - sourceViewportId: viewportId, - }, - true // overwrite - ); - - // make sure to set it to enabled again since we want to recalculate - // the source-target lines - toolGroup.setToolEnabled(ReferenceLinesTool.toolName); - } + activeViewportEventListener ); } diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index 048c6d008b..4de163c791 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -142,12 +142,16 @@ const CornerstoneViewportDownloadForm = ({ const properties = viewport.getProperties(); downloadViewport.setStack([imageId]).then(() => { - downloadViewport.setProperties(properties); - - const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE); - const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE); - - resolve({ width: newWidth, height: newHeight }); + try { + downloadViewport.setProperties(properties); + const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE); + const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE); + + resolve({ width: newWidth, height: newHeight }); + } catch (e) { + // Happens on clicking the cancel button + console.warn('Unable to set properties', e); + } }); } else if (downloadViewport instanceof VolumeViewport) { const actors = viewport.getActors(); diff --git a/extensions/cornerstone/src/utils/stackSync/toggleStackImageSync.ts b/extensions/cornerstone/src/utils/stackSync/toggleStackImageSync.ts index 5e18b6ba4f..65ce12ea8e 100644 --- a/extensions/cornerstone/src/utils/stackSync/toggleStackImageSync.ts +++ b/extensions/cornerstone/src/utils/stackSync/toggleStackImageSync.ts @@ -1,90 +1,80 @@ -import calculateViewportRegistrations from './calculateViewportRegistrations'; +const STACK_SYNC_NAME = 'stackImageSync'; -// [ { -// synchronizerId: string, -// viewports: [ { viewportId: string, renderingEngineId: string, index: number } , ...] -// ]} -let STACK_IMAGE_SYNC_GROUPS_INFO = []; +export default function toggleStackImageSync({ + toggledState, + servicesManager, + viewports: providedViewports, +}) { + if (!toggledState) { + return disableSync(STACK_SYNC_NAME, servicesManager); + } -export default function toggleStackImageSync({ toggledState, servicesManager, getEnabledElement }) { const { syncGroupService, viewportGridService, displaySetService, cornerstoneViewportService } = servicesManager.services; - if (!toggledState) { - STACK_IMAGE_SYNC_GROUPS_INFO.forEach(syncGroupInfo => { - const { viewports, synchronizerId } = syncGroupInfo; + const viewports = providedViewports || getReconstructableStackViewports(viewportGridService, displaySetService); - viewports.forEach(({ viewportId, renderingEngineId }) => { - syncGroupService.removeViewportFromSyncGroup(viewportId, renderingEngineId, synchronizerId); - }); + // create synchronization group and add the viewports to it. + viewports.forEach(gridViewport => { + const { viewportId } = gridViewport.viewportOptions; + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (!viewport) { + return; + } + syncGroupService.addViewportToSyncGroup(viewportId, viewport.getRenderingEngine().id, { + type: 'stackimage', + id: STACK_SYNC_NAME, + source: true, + target: true, }); + }); +} - return; - } - - STACK_IMAGE_SYNC_GROUPS_INFO = []; +function disableSync(syncName, servicesManager) { + const { syncGroupService, viewportGridService, displaySetService, cornerstoneViewportService } = + servicesManager.services; + const viewports = getReconstructableStackViewports(viewportGridService, displaySetService); + viewports.forEach(gridViewport => { + const { viewportId } = gridViewport.viewportOptions; + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (!viewport) { + return; + } + syncGroupService.removeViewportFromSyncGroup( + viewport.id, + viewport.getRenderingEngine().id, + syncName + ); + }); +}; - // create synchronization groups and add viewports - const { viewports } = viewportGridService.getState(); +/** + * Gets the consistent spacing stack viewport types, which are the ones which + * can be navigated using the stack image sync right now. + */ +function getReconstructableStackViewports(viewportGridService, displaySetService) { + let { viewports } = viewportGridService.getState(); + viewports = [...viewports.values()]; // filter empty viewports - const viewportsArray = Array.from(viewports.values()) - .filter(viewport => viewport.displaySetInstanceUIDs?.length) - // filter reconstructable viewports - .filter(viewport => { - const { displaySetInstanceUIDs } = viewport; + viewports = viewports.filter( + viewport => viewport.displaySetInstanceUIDs && viewport.displaySetInstanceUIDs.length + ); - for (const displaySetInstanceUID of displaySetInstanceUIDs) { - const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); - - return !!displaySet?.isReconstructable; - } - }); + // filter reconstructable viewports + viewports = viewports.filter(viewport => { + const { displaySetInstanceUIDs } = viewport; - const viewportsByOrientation = viewportsArray.reduce((acc, viewport) => { - const { viewportId, viewportType } = viewport.viewportOptions; + for (const displaySetInstanceUID of displaySetInstanceUIDs) { + const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); - if (viewportType !== 'stack') { - console.warn('Viewport is not a stack, cannot sync images yet'); - return acc; - } - - const { element } = cornerstoneViewportService.getViewportInfo(viewportId); - const { viewport: csViewport, renderingEngineId } = getEnabledElement(element); - const { viewPlaneNormal } = csViewport.getCamera(); - - // Should we round here? I guess so, but not sure how much precision we need - const orientation = viewPlaneNormal.map(v => Math.round(v)).join(','); + // TODO - add a better test than isReconstructable + if (displaySet && displaySet.isReconstructable) { + return true; + } - if (!acc[orientation]) { - acc[orientation] = []; + return false; } - - acc[orientation].push({ viewportId, renderingEngineId }); - - return acc; - }, {}); - - // create synchronizer for each group - Object.values(viewportsByOrientation).map(viewports => { - let synchronizerId = viewports.map(({ viewportId }) => viewportId).join(','); - - synchronizerId = `imageSync_${synchronizerId}`; - - calculateViewportRegistrations(viewports); - - viewports.forEach(({ viewportId, renderingEngineId }) => { - syncGroupService.addViewportToSyncGroup(viewportId, renderingEngineId, { - type: 'stackimage', - id: synchronizerId, - source: true, - target: true, - }); - }); - - STACK_IMAGE_SYNC_GROUPS_INFO.push({ - synchronizerId, - viewports, - }); }); -} + return viewports; +}; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index ed06eda6ee..2780c31ad0 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -134,7 +134,7 @@ const commandsModule = ({ (!protocolId || protocolId === protocol.id) && (stageIndex === undefined || stageIndex === toggleStageIndex) && (!stageId || stageId === stage.id); - toolbarService.setActive(button.id, isActive); + toolbarService.setToggled(button.id, isActive); }; Object.values(toolbarService.getButtons()).forEach(enableListener); }, diff --git a/extensions/default/src/getDisplaySetMessages.ts b/extensions/default/src/getDisplaySetMessages.ts index e07494464f..53c72ce83b 100644 --- a/extensions/default/src/getDisplaySetMessages.ts +++ b/extensions/default/src/getDisplaySetMessages.ts @@ -19,7 +19,7 @@ export default function getDisplaySetMessages( const firstInstance = instances[0]; // Due to current requirements, LOCALIZER series doesn't have any messages - if (firstInstance.ImageType.includes('LOCALIZER')) { + if (firstInstance?.ImageType?.includes('LOCALIZER')) { return messages; } diff --git a/modes/basic-test-mode/package.json b/modes/basic-test-mode/package.json index f005917eb1..382c4119f1 100644 --- a/modes/basic-test-mode/package.json +++ b/modes/basic-test-mode/package.json @@ -6,7 +6,7 @@ "license": "MIT", "repository": "OHIF/Viewers", "main": "dist/ohif-mode-test.umd.js", - "module": "src/index.js", + "module": "src/index.ts", "engines": { "node": ">=14", "npm": ">=6", diff --git a/modes/basic-test-mode/src/index.js b/modes/basic-test-mode/src/index.ts similarity index 98% rename from modes/basic-test-mode/src/index.js rename to modes/basic-test-mode/src/index.ts index ab68c94699..3d51b06f15 100644 --- a/modes/basic-test-mode/src/index.js +++ b/modes/basic-test-mode/src/index.ts @@ -1,7 +1,7 @@ import { hotkeys } from '@ohif/core'; -import toolbarButtons from './toolbarButtons.js'; -import { id } from './id.js'; -import initToolGroups from './initToolGroups.js'; +import toolbarButtons from './toolbarButtons'; +import { id } from './id'; +import initToolGroups from './initToolGroups'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. diff --git a/modes/basic-test-mode/src/initToolGroups.js b/modes/basic-test-mode/src/initToolGroups.ts similarity index 100% rename from modes/basic-test-mode/src/initToolGroups.js rename to modes/basic-test-mode/src/initToolGroups.ts diff --git a/modes/basic-test-mode/src/toolbarButtons.js b/modes/basic-test-mode/src/toolbarButtons.ts similarity index 91% rename from modes/basic-test-mode/src/toolbarButtons.js rename to modes/basic-test-mode/src/toolbarButtons.ts index 506e6b6529..e91bb9d190 100644 --- a/modes/basic-test-mode/src/toolbarButtons.js +++ b/modes/basic-test-mode/src/toolbarButtons.ts @@ -5,43 +5,15 @@ import { // ListMenu, WindowLevelMenuItem, } from '@ohif/ui'; -import { defaults } from '@ohif/core'; +import { defaults, ToolbarService } from '@ohif/core'; +import type { Button, RunCommand } from '@ohif/core/types'; +import { EVENTS } from '@cornerstonejs/core'; 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, - }; -} -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 _createActionButton = _createButton.bind(null, 'action'); -const _createToggleButton = _createButton.bind(null, 'toggle'); -const _createToolButton = _createButton.bind(null, 'tool'); +const _createActionButton = ToolbarService._createButton.bind(null, 'action'); +const _createToggleButton = ToolbarService._createButton.bind(null, 'toggle'); +const _createToolButton = ToolbarService._createButton.bind(null, 'tool'); /** * @@ -67,7 +39,21 @@ function _createWwwcPreset(preset, title, subtitle) { }; } -const toolbarButtons = [ +const ReferenceLinesCommands: RunCommand = [ + { + commandName: 'setSourceViewportForReferenceLinesTool', + context: 'CORNERSTONE', + }, + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'ReferenceLines', + }, + context: 'CORNERSTONE', + }, +]; + +const toolbarButtons: Button[] = [ // Measurement { id: 'MeasurementTools', @@ -515,24 +501,37 @@ const toolbarButtons = [ ], 'Flip Horizontal' ), - _createToggleButton('StackImageSync', 'link', 'Stack Image Sync', [ + _createToggleButton( + 'StackImageSync', + 'link', + 'Stack Image Sync', + [ + { + commandName: 'toggleStackImageSync', + }, + ], + 'Enable position synchronization on stack viewports', { - commandName: 'toggleStackImageSync', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ]), + listeners: { + [EVENTS.STACK_VIEWPORT_NEW_STACK]: { + commandName: 'toggleStackImageSync', + commandOptions: { toggledState: true }, + }, + }, + } + ), _createToggleButton( 'ReferenceLines', 'tool-referenceLines', // change this with the new icon 'Reference Lines', - [ - { - commandName: 'toggleReferenceLines', - commandOptions: {}, - context: 'CORNERSTONE', + ReferenceLinesCommands, + 'Show Reference Lines', + { + listeners: { + [EVENTS.STACK_VIEWPORT_NEW_STACK]: ReferenceLinesCommands, + [EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesCommands, }, - ] + } ), _createToolButton( 'StackScroll', diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 288d0787c0..c12219c274 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -1,7 +1,7 @@ import { hotkeys } from '@ohif/core'; -import toolbarButtons from './toolbarButtons.js'; -import { id } from './id.js'; -import initToolGroups from './initToolGroups.js'; +import toolbarButtons from './toolbarButtons'; +import { id } from './id'; +import initToolGroups from './initToolGroups'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. diff --git a/modes/longitudinal/src/toolbarButtons.js b/modes/longitudinal/src/toolbarButtons.ts similarity index 90% rename from modes/longitudinal/src/toolbarButtons.js rename to modes/longitudinal/src/toolbarButtons.ts index 821d59c6bd..e7842c5652 100644 --- a/modes/longitudinal/src/toolbarButtons.js +++ b/modes/longitudinal/src/toolbarButtons.ts @@ -5,32 +5,15 @@ import { // ListMenu, WindowLevelMenuItem, } from '@ohif/ui'; -import { defaults } from '@ohif/core'; +import { defaults, ToolbarService } from '@ohif/core'; +import type { Button, RunCommand } from '@ohif/core/types'; +import { EVENTS } from '@cornerstonejs/core'; const { windowLevelPresets } = defaults; -/** - * - * @param {*} type - 'tool' | 'action' | 'toggle' - * @param {*} id - * @param {*} icon - * @param {*} label - */ -function _createButton(type, id, icon, label, commands, tooltip, uiType, isActive) { - return { - id, - icon, - label, - type, - commands, - tooltip, - uiType, - isActive, - }; -} -const _createActionButton = _createButton.bind(null, 'action'); -const _createToggleButton = _createButton.bind(null, 'toggle'); -const _createToolButton = _createButton.bind(null, 'tool'); +const _createActionButton = ToolbarService._createButton.bind(null, 'action'); +const _createToggleButton = ToolbarService._createButton.bind(null, 'toggle'); +const _createToolButton = ToolbarService._createButton.bind(null, 'tool'); /** * @@ -76,7 +59,21 @@ function _createSetToolActiveCommands(toolName) { return temp; } -const toolbarButtons = [ +const ReferenceLinesCommands: RunCommand = [ + { + commandName: 'setSourceViewportForReferenceLinesTool', + context: 'CORNERSTONE', + }, + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'ReferenceLines', + }, + context: 'CORNERSTONE', + }, +]; + +const toolbarButtons: Button[] = [ // Measurement { id: 'MeasurementTools', @@ -422,34 +419,37 @@ const toolbarButtons = [ ], 'Flip Horizontal' ), - _createToggleButton('StackImageSync', 'link', 'Stack Image Sync', [ - { - commandName: 'toggleStackImageSync', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ]), _createToggleButton( - 'ReferenceLines', - 'tool-referenceLines', // change this with the new icon - 'Reference Lines', - // two commands for the reference lines tool: - // - the first to set the source viewport for the tool when it is enabled - // - the second to toggle the tool + 'StackImageSync', + 'link', + 'Stack Image Sync', [ { - commandName: 'setSourceViewportForReferenceLinesTool', - commandOptions: {}, - context: 'CORNERSTONE', + commandName: 'toggleStackImageSync', }, - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'ReferenceLines', + ], + 'Enable position synchronization on stack viewports', + { + listeners: { + [EVENTS.STACK_VIEWPORT_NEW_STACK]: { + commandName: 'toggleStackImageSync', + commandOptions: { toggledState: true }, }, - context: 'CORNERSTONE', }, - ] + } + ), + _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, + }, + } ), _createToggleButton( 'ImageOverlayViewer', @@ -465,8 +465,7 @@ const toolbarButtons = [ }, ], 'Image Overlay', - null, - true + { isActive: true } ), _createToolButton( 'StackScroll', diff --git a/platform/app/.webpack/webpack.pwa.js b/platform/app/.webpack/webpack.pwa.js index 167f44a6f8..af0b70b445 100644 --- a/platform/app/.webpack/webpack.pwa.js +++ b/platform/app/.webpack/webpack.pwa.js @@ -131,6 +131,8 @@ module.exports = (env, argv) => { maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // Need to exclude the theme as it is updated independently exclude: [/theme/], + // Cache large files for the manifests to avoid warning messages + maximumFileSizeToCacheInBytes: 1024 * 1024 * 50, }), ], // https://webpack.js.org/configuration/dev-server/ diff --git a/platform/app/.webpack/writePluginImportsFile.js b/platform/app/.webpack/writePluginImportsFile.js index b9151a32c4..47456d034f 100644 --- a/platform/app/.webpack/writePluginImportsFile.js +++ b/platform/app/.webpack/writePluginImportsFile.js @@ -74,7 +74,7 @@ function getRuntimeLoadModesExtensions(modules) { ); }); dynamicLoad.push( - ' return (await import(module)).default;', + ' return (await import(/* webpackIgnore: true */ module)).default;', '}\n', '// Import a list of items (modules or string names)', '// @return a Promise evaluating to a list of modules', diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js index 117bdea388..e0c509b972 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js @@ -409,20 +409,42 @@ describe('OHIF Cornerstone Toolbar', () => { cy.get('@moreBtn').click(); cy.get('.tooltip-toolbar-overlay').should('not.exist'); }); +*/ + it('check if Flip tool will flip the image in the viewport', () => { + cy.get('@viewportInfoMidLeft').should('contains.text', 'R'); + cy.get('@viewportInfoMidTop').should('contains.text', 'A'); - it('check if Flip V tool will flip the image vertically in the viewport', () => { //Click on More button - cy.get('@moreBtn').click(); - //Verify if overlay is displayed - cy.get('.tooltip-toolbar-overlay').should('be.visible'); + cy.get('@moreBtnSecondary').click(); - //Click on Flip V button - cy.get('[data-cy="flip v"]').click(); - cy.get('@viewportInfoMidLeft').should('contains.text', 'R'); - cy.get('@viewportInfoMidTop').should('contains.text', 'F'); + //Click on Flip button + cy.get('[data-cy="flip-horizontal"]').click(); + cy.waitDicomImage(); + cy.get('@viewportInfoMidLeft').should('contains.text', 'L'); + cy.get('@viewportInfoMidTop').should('contains.text', 'A'); + }); - //Click on More button to close it - cy.get('@moreBtn').click(); - cy.get('.tooltip-toolbar-overlay').should('not.exist'); - });*/ + 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="StackImageSync"]').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}'); + + // 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').type('{downarrow}'); + 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/OHIFDownloadSnapshotFile.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFDownloadSnapshotFile.spec.js index c8dc00d62c..35ef986925 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFDownloadSnapshotFile.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFDownloadSnapshotFile.spec.js @@ -36,6 +36,8 @@ describe('OHIF Download Snapshot File', () => { // Check buttons cy.get('[data-cy="cancel-btn"]').scrollIntoView().should('be.visible'); cy.get('[data-cy="download-btn"]').scrollIntoView().should('be.visible'); + + cy.get('[data-cy="cancel-btn"]').click(); }); /*it('cancel changes on download modal', function() { diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js index 0d01bd546e..420cdd8226 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFGeneralViewer.spec.js @@ -1,11 +1,9 @@ -describe('OHIF Study Viewer Page', function () { - beforeEach(function () { - cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78'); - - cy.expectMinimumThumbnails(3); - cy.initCommonElementsAliases(); - cy.initCornerstoneToolsAliases(); - }); +describe('OHIF General Viewer', function () { + beforeEach(() => + cy.initViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78', { + minimumThumbnails: 3, + }) + ); it('scrolls series stack using scrollbar', function () { cy.scrollToIndex(13); diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js index e92e75e5c0..c43cb2c93a 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js @@ -1,4 +1,4 @@ -describe('OHIF Study Viewer Page', function () { +describe('OHIF Study Browser', function () { beforeEach(function () { cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78'); diff --git a/platform/app/cypress/support/commands.js b/platform/app/cypress/support/commands.js index c2261fd3ab..83b2df9193 100644 --- a/platform/app/cypress/support/commands.js +++ b/platform/app/cypress/support/commands.js @@ -69,6 +69,18 @@ Cypress.Commands.add( } ); +Cypress.Commands.add('initViewer', (StudyInstanceUID, other = {}) => { + const { mode = '/basic-test', minimumThumbnails = 1, params = '' } = other; + cy.openStudyInViewer(StudyInstanceUID, params, mode); + cy.waitDicomImage(); + // Very short wait to ensure pending updates are handled + cy.wait(25); + + cy.expectMinimumThumbnails(minimumThumbnails); + cy.initCommonElementsAliases(); + cy.initCornerstoneToolsAliases(); +}); + Cypress.Commands.add( 'openStudyInViewer', (StudyInstanceUID, otherParams = '', mode = '/basic-test') => { @@ -347,14 +359,9 @@ Cypress.Commands.add('percyCanvasSnapshot', (name, options = {}) => { }); Cypress.Commands.add('setLayout', (columns = 1, rows = 1) => { - cy.get('[data-cy="layout"]').click(); - - cy.get('.layoutChooser') - .find('tr') - .eq(rows - 1) - .find('td') - .eq(columns - 1) - .click(); + cy.get('[data-cy="Layout"]').click(); + + cy.get(`[data-cy="Layout-${columns - 1}-${rows - 1}"]`).click(); cy.wait(10); cy.waitDicomImage(); diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index 8b5f174a1e..bf095af71d 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -38,8 +38,28 @@ window.config = { configuration: { friendlyName: 'Static WADO Local Data', name: 'DCM4CHEE', - qidoRoot: '/dicomweb', - wadoRoot: '/dicomweb', + qidoRoot: 'http://localhost:5000/dicomweb', + wadoRoot: 'http://localhost:5000/dicomweb', + qidoSupportsIncludeField: false, + supportsReject: true, + supportsStow: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: false, + supportsWildcard: true, + staticWado: true, + singlepart: 'bulkdata,video,pdf', + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'docker', + configuration: { + friendlyName: 'Static WADO Docker Data', + name: 'DCM4CHEE', + qidoRoot: 'http://localhost:25080/dicomweb', + wadoRoot: 'http://localhost:25080/dicomweb', qidoSupportsIncludeField: false, supportsReject: true, supportsStow: true, diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index 1e9019e236..3c0040bfb9 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -443,7 +443,7 @@ export default function ModeRoute({ diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index 919ee94635..908af18be8 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -2,12 +2,37 @@ 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'; 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', @@ -18,7 +43,27 @@ export default class ToolbarService extends PubSubService { }, }; - buttons: Record = {}; + public static _createButton( + type: string, + id: string, + icon: string, + label: string, + commands: Command | Commands, + tooltip?: string, + extraOptions?: ExtraButtonOptions + ): Button { + return { + id, + icon, + label, + type, + commands, + tooltip, + ...extraOptions, + }; + } + + buttons: Record = {}; state: { primaryToolId: string; toggles: Record; @@ -54,7 +99,7 @@ export default class ToolbarService extends PubSubService { this.buttons = {}; } - onModeEnter() { + public onModeEnter(): void { this.reset(); } @@ -65,7 +110,7 @@ export default class ToolbarService extends PubSubService { * used for calling the specified interaction. That is, the command is * called with {...commandOptions,...options} */ - recordInteraction(interaction, options?: Record) { + public recordInteraction(interaction, options?: Record) { if (!interaction) { return; } @@ -174,12 +219,18 @@ export default class ToolbarService extends PubSubService { } getActiveTools() { - return [this.state.primaryToolId, ...Object.keys(this.state.toggles)]; + const activeTools = [this.state.primaryToolId]; + Object.keys(this.state.toggles).forEach(key => { + if (this.state.toggles[key]) { + activeTools.push(key); + } + }); + return activeTools; } - /** Sets the toggle state of a button to the isActive state */ - public setActive(id: string, isActive: boolean): void { - if (isActive) { + /** 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]; @@ -197,10 +248,25 @@ export default class ToolbarService extends PubSubService { } } - getButton(id) { + public getButton(id: string): Button { return this.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 || {}; + if (primary?.id === id) { return primary; } + const found = items?.find(childButton => childButton.id === id); + if (found) { + return found; + } + } + } + setButtons(buttons) { this.buttons = buttons; this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { @@ -267,23 +333,22 @@ export default class ToolbarService extends PubSubService { if (!this.buttons[button.id]) { this.buttons[button.id] = button; } - this._setTogglesForButtonItems(button.props?.items); }); + this._setTogglesForButtonItems(buttons); this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {}); } - _setTogglesForButtonItems(buttonItems) { - if (!buttonItems) { + _setTogglesForButtonItems(buttons) { + if (!buttons) { return; } - buttonItems.forEach(buttonItem => { + buttons.forEach(buttonItem => { if (buttonItem.type === 'toggle') { - this.state.toggles[buttonItem.id] = buttonItem.isActive; - } else { - this._setTogglesForButtonItems(buttonItem.props?.items); + this.setToggled(buttonItem.id, buttonItem.isActive); } + this._setTogglesForButtonItems(buttonItem.props?.items); }); } diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts index 837c835907..8a8b8e921e 100644 --- a/platform/core/src/types/Command.ts +++ b/platform/core/src/types/Command.ts @@ -4,7 +4,9 @@ export interface Command { context?: string; } +export type RunCommand = Command | Command[]; + /** A set of commands, typically contained in a tool item or other configuration */ export interface Commands { - commands: Command[]; + commands: RunCommand; } diff --git a/platform/ui/src/components/LayoutSelector/LayoutSelector.tsx b/platform/ui/src/components/LayoutSelector/LayoutSelector.tsx index 038a651349..be90880d61 100644 --- a/platform/ui/src/components/LayoutSelector/LayoutSelector.tsx +++ b/platform/ui/src/components/LayoutSelector/LayoutSelector.tsx @@ -34,6 +34,7 @@ function LayoutSelector({ onSelection, rows, columns }) { border: '1px solid white', backgroundColor: isHovered(index) ? '#5acce6' : '#0b1a42', }} + data-cy={`Layout-${index % columns}-${Math.floor(index / columns)}`} className="cursor-pointer" onClick={() => { const x = index % columns; diff --git a/testdata b/testdata index da63adc206..4d59660c28 160000 --- a/testdata +++ b/testdata @@ -1 +1 @@ -Subproject commit da63adc206455ce860b87c6fe723d71fb415a891 +Subproject commit 4d59660c2883ed749a680e5fb6d4624ab54c9422