diff --git a/extensions/cornerstone/src/panels/PanelMeasurement.tsx b/extensions/cornerstone/src/panels/PanelMeasurement.tsx index b2279d80a3d..97af8cac778 100644 --- a/extensions/cornerstone/src/panels/PanelMeasurement.tsx +++ b/extensions/cornerstone/src/panels/PanelMeasurement.tsx @@ -1,15 +1,10 @@ import React, { useEffect, useRef, useState } from 'react'; import { utils } from '@ohif/core'; -import { useViewportGrid } from '@ohif/ui-next'; import { MeasurementTable } from '@ohif/ui-next'; import debounce from 'lodash.debounce'; import { useMeasurements } from '../hooks/useMeasurements'; -const { - filterAdditionalFindings: filterAdditionalFinding, - filterOr, - filterAny, -} = utils.MeasurementFilters; +const { filterAdditionalFindings: filterAdditionalFinding, filterAny } = utils.MeasurementFilters; export type withAppAndFilters = withAppTypes & { measurementFilter: (item) => boolean; diff --git a/extensions/default/src/Components/MoreDropdownMenu.tsx b/extensions/default/src/Components/MoreDropdownMenu.tsx new file mode 100644 index 00000000000..7bdaddd7eb9 --- /dev/null +++ b/extensions/default/src/Components/MoreDropdownMenu.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Icons, + Button, +} from '@ohif/ui-next'; + +/** + * The default sub-menu appearance and setup is defined here, but this can be + * replaced by + */ +const getMenuItemsDefault = ({ commandsManager, items, servicesManager, ...props }) => { + const { customizationService } = servicesManager.services; + + // This allows replacing the default child item for menus, whereas the entire + // getMenuItems can also be replaced by providing it to the MoreDropdownMenu + const menuContent = customizationService.getCustomization('ohif.menuContent'); + + return ( + { + e.stopPropagation(); + e.preventDefault(); + }} + > + {items?.map(item => + menuContent.content({ + key: item.id, + item, + commandsManager, + servicesManager, + ...props, + }) + )} + + ); +}; + +/** + * The component provides a ... sub-menu for various components which appears + * on hover over the main component. + * + * @param bindProps - properties to define the sub-menu + * @returns Component bound to the bindProps + */ +export default function MoreDropdownMenu(bindProps) { + const { + menuItemsKey, + getMenuItems = getMenuItemsDefault, + commandsManager, + servicesManager, + } = bindProps; + const { customizationService } = servicesManager.services; + + const items = customizationService.getCustomization(menuItemsKey)?.value; + + if (!items) { + return null; + } + + function BoundMoreDropdownMenu(props) { + return ( + + + + + {getMenuItems({ + ...props, + commandsManager: commandsManager, + servicesManager: servicesManager, + items, + })} + + ); + } + return BoundMoreDropdownMenu; +} diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx index 5323308046a..9b7c1078b09 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx @@ -47,15 +47,22 @@ export default class ContextMenuController { } const { event, subMenu, menuId, menus, selectorProps } = contextMenuProps; + if (!menus) { + console.warn('No menus found for', menuId); + return; + } const { locking, visibility } = CsAnnotation; const targetAnnotationId = selectorProps?.nearbyToolData?.annotationUID as string; - const isLocked = locking.isAnnotationLocked(targetAnnotationId); - const isVisible = visibility.isAnnotationVisible(targetAnnotationId); - if (isLocked || !isVisible) { - console.warn(`Annotation is ${isLocked ? 'locked' : 'not visible'}.`); - return; + if (targetAnnotationId) { + const isLocked = locking.isAnnotationLocked(targetAnnotationId); + const isVisible = visibility.isAnnotationVisible(targetAnnotationId); + + if (isLocked || !isVisible) { + console.warn(`Annotation is ${isLocked ? 'locked' : 'not visible'}.`); + return; + } } const items = ContextMenuItemsBuilder.getMenuItems( @@ -73,7 +80,7 @@ export default class ContextMenuController { preventCutOf: true, defaultPosition: ContextMenuController._getDefaultPosition( defaultPointsPosition, - event?.detail, + event?.detail || event, viewportElement ), event, @@ -89,7 +96,7 @@ export default class ContextMenuController { menus, event, subMenu, - eventData: event?.detail, + eventData: event?.detail || event, onClose: () => { this.services.uiDialogService.dismiss({ id: 'context-menu' }); @@ -136,8 +143,8 @@ export default class ContextMenuController { }; static _getEventDefaultPosition = eventDetail => ({ - x: eventDetail && eventDetail.currentPoints.client[0], - y: eventDetail && eventDetail.currentPoints.client[1], + x: eventDetail?.currentPoints?.client[0] ?? eventDetail?.pageX, + y: eventDetail?.currentPoints?.client[1] ?? eventDetail?.pageY, }); static _getElementDefaultPosition = element => { diff --git a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx index 81440b4f8cc..62d0550b126 100644 --- a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'; import { Separator } from '@ohif/ui-next'; import { PanelStudyBrowserHeader } from './PanelStudyBrowserHeader'; import { defaultActionIcons, defaultViewPresets } from './constants'; +import MoreDropdownMenu from '../../Components/MoreDropdownMenu'; const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils; @@ -280,10 +281,6 @@ function PanelStudyBrowser({ const activeDisplaySetInstanceUIDs = viewports.get(activeViewportId)?.displaySetInstanceUIDs; - const onThumbnailContextMenu = (commandName, options) => { - commandsManager.runCommand(commandName, options); - }; - return ( <> <> @@ -304,16 +301,26 @@ function PanelStudyBrowser({ tabs={tabs} servicesManager={servicesManager} activeTabName={activeTabName} - onDoubleClickThumbnail={onDoubleClickThumbnailHandler} - activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs} expandedStudyInstanceUIDs={expandedStudyInstanceUIDs} onClickStudy={_handleStudyClick} onClickTab={clickedTabName => { setActiveTabName(clickedTabName); }} + onClickThumbnail={() => {}} + onDoubleClickThumbnail={onDoubleClickThumbnailHandler} + activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs} showSettings={actionIcons.find(icon => icon.id === 'settings').value} viewPresets={viewPresets} - onThumbnailContextMenu={onThumbnailContextMenu} + ThumbnailMenuItems={MoreDropdownMenu({ + commandsManager, + servicesManager, + menuItemsKey: 'studyBrowser.thumbnailMenuItems', + })} + StudyMenuItems={MoreDropdownMenu({ + commandsManager, + servicesManager, + menuItemsKey: 'studyBrowser.studyMenuItems', + })} /> ); diff --git a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx index f3c2e247398..9495430986e 100644 --- a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx @@ -13,10 +13,10 @@ import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStu * @param {object} commandsManager * @param {object} extensionManager */ -function WrappedPanelStudyBrowser({ commandsManager, extensionManager, servicesManager }) { +function WrappedPanelStudyBrowser({ extensionManager, servicesManager }) { // TODO: This should be made available a different way; route should have // already determined our datasource - const dataSource = extensionManager.getDataSources()[0]; + const [dataSource] = extensionManager.getActiveDataSource(); const _getStudiesForPatientByMRN = getStudiesForPatientByMRN.bind(null, dataSource); const _getImageSrcFromImageId = useCallback( _createGetImageSrcFromImageIdFn(extensionManager), diff --git a/extensions/default/src/Panels/requestDisplaySetCreationForStudy.js b/extensions/default/src/Panels/requestDisplaySetCreationForStudy.js index c10a5d97b2c..9d13180040e 100644 --- a/extensions/default/src/Panels/requestDisplaySetCreationForStudy.js +++ b/extensions/default/src/Panels/requestDisplaySetCreationForStudy.js @@ -13,7 +13,7 @@ function requestDisplaySetCreationForStudy( return; } - dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient }); + return dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient }); } export default requestDisplaySetCreationForStudy; diff --git a/extensions/default/src/ViewerLayout/ViewerHeader.tsx b/extensions/default/src/ViewerLayout/ViewerHeader.tsx index aae0e7a8169..2cbc8c1a4f5 100644 --- a/extensions/default/src/ViewerLayout/ViewerHeader.tsx +++ b/extensions/default/src/ViewerLayout/ViewerHeader.tsx @@ -25,8 +25,6 @@ function ViewerHeader({ const onClickReturnButton = () => { const { pathname } = location; const dataSourceIdx = pathname.indexOf('/', 1); - const query = new URLSearchParams(window.location.search); - const configUrl = query.get('configUrl'); const dataSourceName = pathname.substring(dataSourceIdx + 1); const existingDataSource = extensionManager.getDataSources(dataSourceName); @@ -35,10 +33,7 @@ function ViewerHeader({ if (dataSourceIdx !== -1 && existingDataSource) { searchQuery.append('datasources', pathname.substring(dataSourceIdx + 1)); } - - if (configUrl) { - searchQuery.append('configUrl', configUrl); - } + preserveQueryParameters(searchQuery); navigate({ pathname: publicUrl, diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 8f3b96952ba..9ce75029ab0 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -1,4 +1,4 @@ -import { Types } from '@ohif/core'; +import { Types, DicomMetadataStore } from '@ohif/core'; import { ContextMenuController, defaultContextMenu } from './CustomizableContextMenu'; import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; @@ -16,6 +16,7 @@ import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolSt import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore'; import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore'; import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore'; +import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy'; export type HangingProtocolParams = { protocolId?: string; @@ -33,6 +34,7 @@ export type UpdateViewportDisplaySetParams = { const commandsModule = ({ servicesManager, commandsManager, + extensionManager, }: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => { const { customizationService, @@ -41,12 +43,56 @@ const commandsModule = ({ uiNotificationService, viewportGridService, displaySetService, + multiMonitorService, } = servicesManager.services; // Define a context menu controller for use with any context menus const contextMenuController = new ContextMenuController(servicesManager, commandsManager); const actions = { + /** + * Runs a command in multi-monitor mode. No-op if not multi-monitor. + */ + multimonitor: async options => { + const { screenDelta, StudyInstanceUID, commands, hashParams } = options; + if (multiMonitorService.numberOfScreens < 2) { + return options.fallback?.(options); + } + + const newWindow = await multiMonitorService.launchWindow( + StudyInstanceUID, + screenDelta, + hashParams + ); + + // Only run commands if we successfully got a window with a commands manager + if (newWindow && commands) { + // Todo: fix this properly, but it takes time for the new window to load + // and then the commandsManager is available for it + setTimeout(() => { + multiMonitorService.run(screenDelta, commands, options); + }, 1000); + } + }, + + /** + * Ensures that the specified study is available for display + * Then, if commands is specified, runs the given commands list/instance + */ + loadStudy: async options => { + const { StudyInstanceUID } = options; + const displaySets = displaySetService.getActiveDisplaySets(); + const isActive = displaySets.find(ds => ds.StudyInstanceUID === StudyInstanceUID); + if (isActive) { + return; + } + const [dataSource] = extensionManager.getActiveDataSource(); + await requestDisplaySetCreationForStudy(dataSource, displaySetService, StudyInstanceUID); + + const study = DicomMetadataStore.getStudy(StudyInstanceUID); + hangingProtocolService.addStudy(study); + }, + /** * Show the context menu. * @param options.menuId defines the menu name to lookup, from customizationService @@ -135,11 +181,13 @@ const commandsModule = ({ */ setHangingProtocol: ({ activeStudyUID = '', + StudyInstanceUID = '', protocolId, stageId, stageIndex, reset = false, }: HangingProtocolParams): boolean => { + const toUseStudyInstanceUID = activeStudyUID || StudyInstanceUID; 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 @@ -158,7 +206,7 @@ const commandsModule = ({ } } else if (stageIndex === undefined && stageId === undefined) { // Re-set the same stage as was previously used - const hangingId = `${activeStudyUID || hpInfo.activeStudyUID}:${protocolId}`; + const hangingId = `${toUseStudyInstanceUID || hpInfo.activeStudyUID}:${protocolId}`; stageIndex = hangingProtocolStageIndexMap[hangingId]?.stageIndex; } @@ -169,11 +217,9 @@ const commandsModule = ({ stageIndex, }); - if (activeStudyUID) { - hangingProtocolService.setActiveStudyUID(activeStudyUID); - } + const activeStudyChanged = hangingProtocolService.setActiveStudyUID(toUseStudyInstanceUID); - const storedHanging = `${hangingProtocolService.getState().activeStudyUID}:${protocolId}:${ + const storedHanging = `${toUseStudyInstanceUID || hangingProtocolService.getState().activeStudyUID}:${protocolId}:${ useStageIdx || 0 }`; @@ -181,9 +227,25 @@ const commandsModule = ({ const restoreProtocol = !reset && viewportGridState[storedHanging]; if ( + reset || + (activeStudyChanged && + !viewportGridState[storedHanging] && + stageIndex === undefined && + stageId === undefined) + ) { + // Run the hanging protocol fresh, re-using the existing study data + // This is done on reset or when the study changes and we haven't yet + // applied it, and don't specify exact stage to use. + const displaySets = displaySetService.getActiveDisplaySets(); + const activeStudy = { + StudyInstanceUID: toUseStudyInstanceUID, + displaySets, + }; + hangingProtocolService.run(activeStudy, protocolId); + } else if ( protocolId === hpInfo.protocolId && useStageIdx === hpInfo.stageIndex && - !activeStudyUID + !toUseStudyInstanceUID ) { // Clear the HP setting to reset them hangingProtocolService.setProtocol(protocolId, { @@ -204,7 +266,7 @@ const commandsModule = ({ // Do this after successfully applying the update const { setDisplaySetSelector } = useDisplaySetSelectorStore.getState(); setDisplaySetSelector( - `${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0`, + `${toUseStudyInstanceUID || hpInfo.activeStudyUID}:activeDisplaySet:0`, null ); return true; @@ -562,6 +624,8 @@ const commandsModule = ({ }; const definitions = { + multimonitor: actions.multimonitor, + loadStudy: actions.loadStudy, showContextMenu: actions.showContextMenu, closeContextMenu: actions.closeContextMenu, clearMeasurements: actions.clearMeasurements, diff --git a/extensions/default/src/customizations/studyBrowserContextMenu.ts b/extensions/default/src/customizations/studyBrowserContextMenu.ts new file mode 100644 index 00000000000..a7399be3357 --- /dev/null +++ b/extensions/default/src/customizations/studyBrowserContextMenu.ts @@ -0,0 +1,81 @@ +export const studyBrowserContextMenu = { + id: 'StudyBrowser.studyContextMenu', + customizationType: 'ohif.contextMenu', + menus: [ + { + id: 'studyBrowserContextMenu', + // selector restricts context menu to when there is nearbyToolData + items: [ + { + label: 'Show in Grid', + commands: { + commandName: 'loadStudy', + commandOptions: { + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/mnGrid8', + }, + }, + }, + }, + }, + { + label: 'Show in other monitor', + selector: ({ isMultimonitor }) => isMultimonitor, + commands: { + commandName: 'multimonitor', + commandOptions: { + commands: { + commandName: 'loadStudy', + commandOptions: { + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/mnGrid8', + }, + }, + }, + }, + }, + }, + }, + { + label: 'Compare All', + selector: ({ isMultimonitor }) => isMultimonitor, + commands: [ + { + commandName: 'loadStudy', + commandOptions: { + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/mnGrid', + }, + }, + }, + }, + { + commandName: 'multimonitor', + commandOptions: { + commands: { + commandName: 'loadStudy', + commandOptions: { + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/mnGridMonitor2', + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + ], +}; + +export default studyBrowserContextMenu; diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index bceeefdaf7c..af719e17f3f 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -5,6 +5,15 @@ import { ProgressDropdownWithService } from './Components/ProgressDropdownWithSe import DataSourceConfigurationComponent from './Components/DataSourceConfigurationComponent'; import { GoogleCloudDataSourceConfigurationAPI } from './DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI'; import { utils } from '@ohif/core'; +import studyBrowserContextMenu from './customizations/studyBrowserContextMenu'; +import { + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuPortal, + DropdownMenuSubContent, + DropdownMenuItem, + Icons, +} from '@ohif/ui-next'; const formatDate = utils.formatDate; @@ -47,7 +56,74 @@ export default function getCustomizationModule({ servicesManager, extensionManag ], }, }, - + { + name: 'multimonitor', + merge: 'Append', + value: { + id: 'studyBrowser.studyMenuItems', + customizationType: 'ohif.menuContent', + value: [ + { + id: 'applyHangingProtocol', + label: 'Apply Hanging Protocol', + iconName: 'ViewportViews', + items: [ + { + id: 'applyDefaultProtocol', + label: 'Default', + commands: [ + 'loadStudy', + { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: 'default', + }, + }, + ], + }, + { + id: 'applyMPRProtocol', + label: '2x2 Grid', + commands: [ + 'loadStudy', + { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/mnGrid', + }, + }, + ], + }, + ], + }, + { + id: 'showInOtherMonitor', + label: 'Launch On Second Monitor', + iconName: 'DicomTagBrowser', + // we should use evaluator for this, as these are basically toolbar buttons + selector: ({ servicesManager }) => { + const { multiMonitorService } = servicesManager.services; + return multiMonitorService.isMultimonitor; + }, + commands: { + commandName: 'multimonitor', + commandOptions: { + hashParams: '&hangingProtocolId=@ohif/mnGrid8', + commands: [ + 'loadStudy', + { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/mnGrid8', + }, + }, + ], + }, + }, + }, + ], + }, + }, { name: 'default', value: [ @@ -117,7 +193,6 @@ export default function getCustomizationModule({ servicesManager, extensionManag ); }, }, - { id: 'ohif.contextMenu', @@ -140,6 +215,7 @@ export default function getCustomizationModule({ servicesManager, extensionManag return clonedObject; }, }, + studyBrowserContextMenu, { // the generic GUI component to configure a data source using an instance of a BaseDataSourceConfigurationAPI @@ -200,6 +276,69 @@ export default function getCustomizationModule({ servicesManager, extensionManag }, ], }, + { + id: 'studyBrowser.thumbnailMenuItems', + value: [ + { + id: 'tagBrowser', + label: 'Tag Browser', + iconName: 'DicomTagBrowser', + commands: 'openDICOMTagViewer', + }, + ], + }, + { + id: 'ohif.menuContent', + content: function (props) { + const { item, commandsManager, servicesManager, ...rest } = props; + + // If item has sub-items, render a submenu + if (item.items) { + return ( + + + {item.iconName && ( + + )} + {item.label} + + + + {item.items.map(subItem => this.content({ ...props, item: subItem }))} + + + + ); + } + + // Regular menu item + const isDisabled = item.selector && !item.selector({ servicesManager }); + + return ( + { + commandsManager.runAsync(item.commands, { + ...item.commandOptions, + ...rest, + }); + }} + className="gap-[6px]" + > + {item.iconName && ( + + )} + {item.label} + + ); + }, + }, ], }, ]; diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index a65891ea56b..8eea3ba9e9e 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -1,4 +1,4 @@ -import hpMNGrid from './hangingprotocols/hpMNGrid'; +import { hpMN, hpMN8, hpMNMonitor2 } from './hangingprotocols/hpMNGrid'; import hpMNCompare from './hangingprotocols/hpCompare'; import hpMammography from './hangingprotocols/hpMammo'; import hpScale from './hangingprotocols/hpScale'; @@ -142,8 +142,16 @@ function getHangingProtocolModule() { }, // Create a MxN hanging protocol available by default { - name: hpMNGrid.id, - protocol: hpMNGrid, + name: hpMN.id, + protocol: hpMN, + }, + { + name: hpMN8.id, + protocol: hpMN8, + }, + { + name: hpMNMonitor2.id, + protocol: hpMNMonitor2, }, ]; } diff --git a/extensions/default/src/hangingprotocols/hpMNGrid.ts b/extensions/default/src/hangingprotocols/hpMNGrid.ts index 0c31815d258..ebdbd84b08d 100644 --- a/extensions/default/src/hangingprotocols/hpMNGrid.ts +++ b/extensions/default/src/hangingprotocols/hpMNGrid.ts @@ -1,4 +1,7 @@ import { Types } from '@ohif/core'; +import { studyWithImages } from './utils/studySelectors'; +import { seriesWithImages } from './utils/seriesSelectors'; +import { viewportOptions } from './utils/viewportOptions'; /** * Sync group configuration for hydrating segmentations across viewports @@ -21,41 +24,15 @@ export const HYDRATE_SEG_SYNC_GROUP = { * `&hangingProtocolId=@ohif/mnGrid` added to the viewer URL * It is not included in the viewer mode by default. */ -const hpMN: Types.HangingProtocol.Protocol = { +export const hpMN: Types.HangingProtocol.Protocol = { id: '@ohif/mnGrid', description: 'Has various hanging protocol grid layouts', name: '2x2', - protocolMatchingRules: [ - { - id: 'OneOrMoreSeries', - weight: 25, - attribute: 'numberOfDisplaySetsWithImages', - constraint: { - greaterThan: 0, - }, - }, - ], + protocolMatchingRules: studyWithImages, toolGroupIds: ['default'], displaySetSelectors: { defaultDisplaySetId: { - seriesMatchingRules: [ - { - attribute: 'numImageFrames', - constraint: { - greaterThan: { value: 0 }, - }, - required: true, - }, - // This display set will select the specified items by preference - // It has no affect if nothing is specified in the URL. - { - attribute: 'isDisplaySetFromUrl', - weight: 20, - constraint: { - equals: true, - }, - }, - ], + seriesMatchingRules: seriesWithImages, }, }, defaultViewport: { @@ -90,21 +67,7 @@ const hpMN: Types.HangingProtocol.Protocol = { }, viewports: [ { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - syncGroups: [ - { - type: 'hydrateseg', - id: 'sameFORId', - source: true, - target: true, - options: { - matchingRules: ['sameFOR'], - }, - }, - ], - }, + viewportOptions, displaySets: [ { id: 'defaultDisplaySetId', @@ -112,10 +75,7 @@ const hpMN: Types.HangingProtocol.Protocol = { ], }, { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - }, + viewportOptions, displaySets: [ { matchedDisplaySetsIndex: 1, @@ -124,21 +84,7 @@ const hpMN: Types.HangingProtocol.Protocol = { ], }, { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - syncGroups: [ - { - type: 'hydrateseg', - id: 'sameFORId', - source: true, - target: true, - // options: { - // matchingRules: ['sameFOR'], - // }, - }, - ], - }, + viewportOptions, displaySets: [ { matchedDisplaySetsIndex: 2, @@ -147,21 +93,7 @@ const hpMN: Types.HangingProtocol.Protocol = { ], }, { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - syncGroups: [ - { - type: 'hydrateseg', - id: 'sameFORId', - source: true, - target: true, - // options: { - // matchingRules: ['sameFOR'], - // }, - }, - ], - }, + viewportOptions, displaySets: [ { matchedDisplaySetsIndex: 3, @@ -174,11 +106,7 @@ const hpMN: Types.HangingProtocol.Protocol = { // 3x1 stage { - id: '3x1', - // Obsolete settings: - requiredViewports: 1, - preferredViewports: 3, - // New equivalent: + name: '3x1', stageActivation: { enabled: { minViewportsMatched: 3, @@ -193,10 +121,7 @@ const hpMN: Types.HangingProtocol.Protocol = { }, viewports: [ { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - }, + viewportOptions, displaySets: [ { id: 'defaultDisplaySetId', @@ -204,10 +129,7 @@ const hpMN: Types.HangingProtocol.Protocol = { ], }, { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - }, + viewportOptions, displaySets: [ { id: 'defaultDisplaySetId', @@ -216,10 +138,7 @@ const hpMN: Types.HangingProtocol.Protocol = { ], }, { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - }, + viewportOptions, displaySets: [ { id: 'defaultDisplaySetId', @@ -232,9 +151,7 @@ const hpMN: Types.HangingProtocol.Protocol = { // A 2x1 stage { - id: '2x1', - requiredViewports: 1, - preferredViewports: 2, + name: '2x1', stageActivation: { enabled: { minViewportsMatched: 2, @@ -249,10 +166,7 @@ const hpMN: Types.HangingProtocol.Protocol = { }, viewports: [ { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - }, + viewportOptions, displaySets: [ { id: 'defaultDisplaySetId', @@ -260,10 +174,7 @@ const hpMN: Types.HangingProtocol.Protocol = { ], }, { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - }, + viewportOptions, displaySets: [ { matchedDisplaySetsIndex: 1, @@ -276,9 +187,7 @@ const hpMN: Types.HangingProtocol.Protocol = { // A 1x1 stage - should be automatically activated if there is only 1 viewable instance { - id: '1x1', - requiredViewports: 1, - preferredViewports: 1, + name: '1x1', stageActivation: { enabled: { minViewportsMatched: 1, @@ -293,10 +202,7 @@ const hpMN: Types.HangingProtocol.Protocol = { }, viewports: [ { - viewportOptions: { - toolGroupId: 'default', - allowUnmatchedView: true, - }, + viewportOptions, displaySets: [ { id: 'defaultDisplaySetId', @@ -309,4 +215,341 @@ const hpMN: Types.HangingProtocol.Protocol = { numberOfPriorsReferenced: -1, }; +/** + * This hanging protocol can be activated on the primary mode by directly + * referencing it in a URL or by directly including it within a mode, e.g.: + * `&hangingProtocolId=@ohif/mnGrid8` added to the viewer URL + * It is not included in the viewer mode by default. + */ +export const hpMN8: Types.HangingProtocol.Protocol = { + ...hpMN, + id: '@ohif/mnGrid8', + description: 'Has various hanging protocol grid layouts up to 4x2', + name: '4x2', + stages: [ + { + id: '4x2', + name: '4x2', + stageActivation: { + enabled: { + minViewportsMatched: 7, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 4, + }, + }, + viewports: [ + { + viewportOptions, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 1, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 2, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 3, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 4, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 5, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 6, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 7, + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + + { + id: '3x2', + name: '3x2', + stageActivation: { + enabled: { + minViewportsMatched: 5, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 3, + }, + }, + viewports: [ + { + viewportOptions, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 1, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 2, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 3, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 4, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 5, + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + + ...hpMN.stages, + ], +}; + +/** + * This hanging protocol extends the default protocol with additional + * images on the second monitor. It assumes the first four images are shown + * on monitor 0 + */ +export const hpMNMonitor2: Types.HangingProtocol.Protocol = { + ...hpMN, + id: '@ohif/mnGridMonitor2', + description: 'Second monitor HP with 2x2 grid', + name: '2x2 Monitor 2', + stages: [ + { + id: '2x2', + name: '2x2', + stageActivation: { + enabled: { + minViewportsMatched: 3, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 2, + }, + }, + viewports: [ + { + viewportOptions, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 4, + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 5, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 6, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 7, + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + + // A 2x1 stage + { + name: '2x1', + stageActivation: { + enabled: { + minViewportsMatched: 1, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 2, + }, + }, + viewports: [ + { + viewportOptions, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 4, + }, + ], + }, + { + viewportOptions, + displaySets: [ + { + matchedDisplaySetsIndex: 5, + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + + { + name: '1x1', + stageActivation: { + enabled: { + minViewportsMatched: 0, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 1, + }, + }, + viewports: [ + { + viewportOptions, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 4, + }, + ], + }, + ], + }, + + { + name: '1x1 Base', + stageActivation: { + enabled: { + minViewportsMatched: 1, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 1, + }, + }, + viewports: [ + { + viewportOptions, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 0, + }, + ], + }, + ], + }, + ], +}; + export default hpMN; diff --git a/extensions/default/src/hangingprotocols/hpMammo.ts b/extensions/default/src/hangingprotocols/hpMammo.ts index 8890b68432f..4a97b04aa87 100644 --- a/extensions/default/src/hangingprotocols/hpMammo.ts +++ b/extensions/default/src/hangingprotocols/hpMammo.ts @@ -7,7 +7,7 @@ import { LCCPrior, RMLOPrior, LMLOPrior, -} from './mammoDisplaySetSelector'; +} from './utils/mammoDisplaySetSelector'; const rightDisplayArea = { storeAsInitialCamera: true, diff --git a/extensions/default/src/hangingprotocols/index.ts b/extensions/default/src/hangingprotocols/index.ts index ab5b11f2325..57d00c28182 100644 --- a/extensions/default/src/hangingprotocols/index.ts +++ b/extensions/default/src/hangingprotocols/index.ts @@ -1,9 +1,10 @@ -import viewCodeAttribute from './viewCode'; -import lateralityAttribute from './laterality'; -import registerHangingProtocolAttributes from './registerHangingProtocolAttributes'; +import viewCodeAttribute from './utils/viewCode'; +import lateralityAttribute from './utils/laterality'; +import registerHangingProtocolAttributes from './utils/registerHangingProtocolAttributes'; import hpMammography from './hpMammo'; import hpMNGrid from './hpMNGrid'; import hpCompare from './hpCompare'; +export * from './hpMNGrid'; export { viewCodeAttribute, diff --git a/extensions/default/src/hangingprotocols/laterality.ts b/extensions/default/src/hangingprotocols/utils/laterality.ts similarity index 100% rename from extensions/default/src/hangingprotocols/laterality.ts rename to extensions/default/src/hangingprotocols/utils/laterality.ts diff --git a/extensions/default/src/hangingprotocols/mammoDisplaySetSelector.ts b/extensions/default/src/hangingprotocols/utils/mammoDisplaySetSelector.ts similarity index 100% rename from extensions/default/src/hangingprotocols/mammoDisplaySetSelector.ts rename to extensions/default/src/hangingprotocols/utils/mammoDisplaySetSelector.ts diff --git a/extensions/default/src/hangingprotocols/registerHangingProtocolAttributes.ts b/extensions/default/src/hangingprotocols/utils/registerHangingProtocolAttributes.ts similarity index 100% rename from extensions/default/src/hangingprotocols/registerHangingProtocolAttributes.ts rename to extensions/default/src/hangingprotocols/utils/registerHangingProtocolAttributes.ts diff --git a/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts b/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts new file mode 100644 index 00000000000..ea77a304031 --- /dev/null +++ b/extensions/default/src/hangingprotocols/utils/seriesSelectors.ts @@ -0,0 +1,23 @@ +import { Types } from '@ohif/core'; + +type MatchingRule = Types.HangingProtocol.MatchingRule; + +export const seriesWithImages: MatchingRule[] = [ + { + attribute: 'numImageFrames', + constraint: { + greaterThan: { value: 0 }, + }, + weight: 1, + required: true, + }, + // This display set will select the specified items by preference + // It has no affect if nothing is specified in the URL. + { + attribute: 'isDisplaySetFromUrl', + weight: 20, + constraint: { + equals: true, + }, + }, +]; diff --git a/extensions/default/src/hangingprotocols/utils/studySelectors.ts b/extensions/default/src/hangingprotocols/utils/studySelectors.ts new file mode 100644 index 00000000000..029a4461be6 --- /dev/null +++ b/extensions/default/src/hangingprotocols/utils/studySelectors.ts @@ -0,0 +1,14 @@ +import { Types } from '@ohif/core'; + +type MatchingRule = Types.HangingProtocol.MatchingRule; + +export const studyWithImages: MatchingRule[] = [ + { + id: 'OneOrMoreSeries', + weight: 25, + attribute: 'numberOfDisplaySetsWithImages', + constraint: { + greaterThan: 0, + }, + }, +]; diff --git a/extensions/default/src/hangingprotocols/viewCode.ts b/extensions/default/src/hangingprotocols/utils/viewCode.ts similarity index 100% rename from extensions/default/src/hangingprotocols/viewCode.ts rename to extensions/default/src/hangingprotocols/utils/viewCode.ts diff --git a/extensions/default/src/hangingprotocols/utils/viewportOptions.ts b/extensions/default/src/hangingprotocols/utils/viewportOptions.ts new file mode 100644 index 00000000000..5a3ab9838e4 --- /dev/null +++ b/extensions/default/src/hangingprotocols/utils/viewportOptions.ts @@ -0,0 +1,18 @@ +/** A default viewport options */ +export const viewportOptions = { + toolGroupId: 'default', + allowUnmatchedView: true, + syncGroups: [ + { + type: 'hydrateseg', + id: 'sameFORId', + source: true, + target: true, + options: { + matchingRules: ['sameFOR'], + }, + }, + ], +}; + +export const hydrateSegDefault = viewportOptions; diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index 3c4577a52f7..782184fe268 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -37,6 +37,8 @@ import promptLabelAnnotation from './utils/promptLabelAnnotation'; import usePatientInfo from './hooks/usePatientInfo'; import { PanelStudyBrowserHeader } from './Panels/StudyBrowser/PanelStudyBrowserHeader'; import * as utils from './utils'; +import MoreDropdownMenu from './Components/MoreDropdownMenu'; +import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy'; const defaultExtension: Types.Extensions.Extension = { /** @@ -102,4 +104,6 @@ export { usePatientInfo, PanelStudyBrowserHeader, utils, + MoreDropdownMenu, + requestDisplaySetCreationForStudy, }; diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index 8ad4a814574..9ff02336ee6 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -4,14 +4,13 @@ import { useTranslation } from 'react-i18next'; import PropTypes from 'prop-types'; import { utils } from '@ohif/core'; import { useImageViewer, Dialog, ButtonEnums } from '@ohif/ui'; -import { useViewportGrid } from '@ohif/ui-next'; +import { useViewportGrid, DropdownMenu, DropdownMenuTrigger, Icons, Button } from '@ohif/ui-next'; import { StudyBrowser } from '@ohif/ui-next'; import { useTrackedMeasurements } from '../../getContextModule'; import { Separator } from '@ohif/ui-next'; -import { PanelStudyBrowserHeader } from '@ohif/extension-default'; +import { PanelStudyBrowserHeader, MoreDropdownMenu } from '@ohif/extension-default'; import { defaultActionIcons, defaultViewPresets } from './constants'; - const { formatDate, createStudyBrowserTabs } = utils; const thumbnailNoImageModalities = [ 'SR', @@ -24,6 +23,7 @@ const thumbnailNoImageModalities = [ 'OT', 'PMAP', ]; + /** * * @param {*} param0 @@ -485,10 +485,6 @@ export default function PanelStudyBrowserTracking({ }); }; - const onThumbnailContextMenu = (commandName, options) => { - commandsManager.runCommand(commandName, options); - }; - return ( <> <> @@ -522,7 +518,16 @@ export default function PanelStudyBrowserTracking({ activeDisplaySetInstanceUIDs={activeViewportDisplaySetInstanceUIDs} showSettings={actionIcons.find(icon => icon.id === 'settings').value} viewPresets={viewPresets} - onThumbnailContextMenu={onThumbnailContextMenu} + ThumbnailMenuItems={MoreDropdownMenu({ + commandsManager, + servicesManager, + menuItemsKey: 'studyBrowser.thumbnailMenuItems', + })} + StudyMenuItems={MoreDropdownMenu({ + commandsManager, + servicesManager, + menuItemsKey: 'studyBrowser.studyMenuItems', + })} /> ); @@ -592,7 +597,6 @@ function _mapDisplaySets( .forEach(ds => { const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; const componentType = _getComponentType(ds); - const numPanes = viewportGridService.getNumViewportPanes(); const array = componentType === 'thumbnailTracked' ? thumbnailDisplaySets : thumbnailNoImageDisplaySets; diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx index 6a10b84cc99..7ed1a10ccaf 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; // import PanelStudyBrowserTracking from './PanelStudyBrowserTracking'; import getImageSrcFromImageId from './getImageSrcFromImageId'; -import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStudy'; +import { requestDisplaySetCreationForStudy } from '@ohif/extension-default'; function _getStudyForPatientUtility(extensionManager) { const utilityModule = extensionManager.getModuleEntry( diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/requestDisplaySetCreationForStudy.js b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/requestDisplaySetCreationForStudy.js deleted file mode 100644 index 7da70cb9f94..00000000000 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/requestDisplaySetCreationForStudy.js +++ /dev/null @@ -1,18 +0,0 @@ -function requestDisplaySetCreationForStudy( - dataSource, - displaySetService, - StudyInstanceUID, - madeInClient -) { - if ( - displaySetService.activeDisplaySets.some( - displaySet => displaySet.StudyInstanceUID === StudyInstanceUID - ) - ) { - return; - } - - dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient }); -} - -export default requestDisplaySetCreationForStudy; diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 64852361861..4b998b16dbb 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -25,6 +25,67 @@ window.config = { prefetch: 25, }, // filterQueryParam: false, + // Defines multi-monitor layouts + multimonitor: [ + { + id: 'split', + test: ({ multimonitor }) => multimonitor === 'split', + screens: [ + { + id: 'ohif0', + screen: null, + location: { + screen: 0, + width: 0.5, + height: 1, + left: 0, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: null, + location: { + width: 0.5, + height: 1, + left: 0.5, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + + { + id: '2', + test: ({ multimonitor }) => multimonitor === '2', + screens: [ + { + id: 'ohif0', + screen: 0, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: 1, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + ], defaultDataSourceName: 'dicomweb', /* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */ // dangerouslyUseDynamicConfig: { diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index d90939babc5..f5c3dca0ef0 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -21,6 +21,67 @@ window.config = { investigationalUseDialog: { option: 'never', }, + // Defines multi-monitor layouts + multimonitor: [ + { + id: 'split', + test: ({ multimonitor }) => multimonitor === 'split', + screens: [ + { + id: 'ohif0', + screen: null, + location: { + screen: 0, + width: 0.5, + height: 1, + left: 0, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: null, + location: { + width: 0.5, + height: 1, + left: 0.5, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + + { + id: '2', + test: ({ multimonitor }) => multimonitor === '2', + screens: [ + { + id: 'ohif0', + screen: 0, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: 1, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + ], dataSources: [ { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index ce864b0549e..db1c5449833 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -20,6 +20,7 @@ import { PanelService, WorkflowStepsService, StudyPrefetcherService, + MultiMonitorService, // utils, } from '@ohif/core'; @@ -58,6 +59,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { servicesManager.setExtensionManager(extensionManager); servicesManager.registerServices([ + [MultiMonitorService.REGISTRATION, appConfig.multimonitor], UINotificationService.REGISTRATION, UIModalService.REGISTRATION, UIDialogService.REGISTRATION, diff --git a/platform/app/src/hooks/useSearchParams.ts b/platform/app/src/hooks/useSearchParams.ts index 3ceaebd9de8..ccac9dff294 100644 --- a/platform/app/src/hooks/useSearchParams.ts +++ b/platform/app/src/hooks/useSearchParams.ts @@ -3,13 +3,19 @@ import { useLocation } from 'react-router'; /** * It returns a URLSearchParams of the query parameters in the URL, where the keys are * either lowercase or maintain their case based on the lowerCaseKeys parameter. + * This will automatically include the hash parameters as preferred parameters * @param {lowerCaseKeys:boolean} true to return lower case keys; false (default) to maintain casing; * @returns {URLSearchParams} */ export default function useSearchParams(options = { lowerCaseKeys: false }) { const { lowerCaseKeys } = options; - const searchParams = new URLSearchParams(useLocation().search); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const hashParams = new URLSearchParams(location.hash?.substring(1) || ''); + for (const [key, value] of hashParams) { + searchParams.set(key, value); + } if (!lowerCaseKeys) { return searchParams; } diff --git a/platform/app/src/index.js b/platform/app/src/index.js index a7aca10cc7b..4600014b838 100644 --- a/platform/app/src/index.js +++ b/platform/app/src/index.js @@ -5,8 +5,6 @@ import 'regenerator-runtime/runtime'; import { createRoot } from 'react-dom/client'; import App from './App'; import React from 'react'; -import { history } from './utils/history'; -export { publicUrl } from './utils/publicUrl'; /** * EXTENSIONS AND MODES @@ -19,6 +17,9 @@ export { publicUrl } from './utils/publicUrl'; */ import { modes as defaultModes, extensions as defaultExtensions } from './pluginImports'; import loadDynamicConfig from './loadDynamicConfig'; +export { history } from './utils/history'; +export { preserveQueryParameters, preserveQueryStrings } from './utils/preserveQueryParameters'; +export { publicUrl } from './utils/publicUrl'; loadDynamicConfig(window.config).then(config_json => { // Reset Dynamic config if defined @@ -41,5 +42,3 @@ loadDynamicConfig(window.config).then(config_json => { const root = createRoot(container); root.render(React.createElement(App, appProps)); }); - -export { history }; diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index 3d9c8b38ccb..0475a574650 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -43,6 +43,7 @@ import { import { Types } from '@ohif/ui'; import i18n from '@ohif/i18n'; +import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters'; const PatientInfoVisibility = Types.PatientInfoVisibility; @@ -206,11 +207,12 @@ function WorkList({ } }); + preserveQueryStrings(queryString); + const search = qs.stringify(queryString, { skipNull: true, skipEmptyString: true, }); - navigate({ pathname: publicUrl, search: search ? `?${search}` : undefined, @@ -413,6 +415,7 @@ function WorkList({ query.append('configUrl', filterValues.configUrl); } query.append('StudyInstanceUIDs', studyInstanceUid); + preserveQueryParameters(query); return ( mode.displayName && ( @@ -640,7 +643,6 @@ const defaultFilterValues = { pageNumber: 1, resultsPerPage: 25, datasources: '', - configUrl: null, }; function _tryParseInt(str, defaultValue) { diff --git a/platform/app/src/routes/index.tsx b/platform/app/src/routes/index.tsx index c3baf6b4ebe..5a4e5c51083 100644 --- a/platform/app/src/routes/index.tsx +++ b/platform/app/src/routes/index.tsx @@ -112,8 +112,7 @@ const createRoutes = ({ const allRoutes = [ ...routes, ...(showStudyList ? [WorkListRoute] : []), - // This next line adds a route on / to allow loading from the route and redirecting to the public url - ...(publicUrl !== '/' && showStudyList ? [{ ...WorkListRoute, path: '/' }] : []), + ...(publicUrl !== '/' && showStudyList ? [{ ...WorkListRoute, path: publicUrl }] : []), ...(customRoutes?.routes || []), ...bakedInRoutes, customRoutes?.notFoundRoute || notFoundRoute, diff --git a/platform/app/src/utils/preserveQueryParameters.ts b/platform/app/src/utils/preserveQueryParameters.ts new file mode 100644 index 00000000000..405e9b93a4a --- /dev/null +++ b/platform/app/src/utils/preserveQueryParameters.ts @@ -0,0 +1,26 @@ +function preserve(query, current, key) { + const value = current.get(key); + if (value) { + query.append(key, value); + } +} + +export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber']; + +export function preserveQueryParameters( + query, + current = new URLSearchParams(window.location.search) +) { + for (const key of preserveKeys) { + preserve(query, current, key); + } +} + +export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) { + for (const key of preserveKeys) { + const value = current.get(key); + if (value) { + query[key] = value; + } + } +} diff --git a/platform/core/src/classes/CommandsManager.ts b/platform/core/src/classes/CommandsManager.ts index 096054da809..708654f803f 100644 --- a/platform/core/src/classes/CommandsManager.ts +++ b/platform/core/src/classes/CommandsManager.ts @@ -1,6 +1,8 @@ import log from '../log.js'; import { Command, Commands, ComplexCommand } from '../types/Command'; +export type RunInput = Command | Commands | Command[] | string | undefined; + /** * The definition of a command * @@ -157,6 +159,44 @@ export class CommandsManager { } } + public static convertCommands(toRun: Command | Commands | Command[] | string) { + if (typeof toRun === 'string') { + return [{ commandName: toRun }]; + } + if ('commandName' in toRun) { + return [toRun as ComplexCommand]; + } + if ('commands' in toRun) { + const commandsInput = (toRun as Commands).commands; + return this.convertCommands(commandsInput); + } + if (Array.isArray(toRun)) { + return toRun.map(command => CommandsManager.convertCommands(command)[0]); + } + + return []; + } + + private validate(input: RunInput, options: Record = {}): ComplexCommand[] { + if (!input) { + console.debug('No command to run'); + return []; + } + + // convert commands + const converted: ComplexCommand[] = CommandsManager.convertCommands(input); + if (!converted.length) { + console.debug('Command is not runnable', input); + return []; + } + + return converted.map(command => ({ + commandName: command.commandName, + commandOptions: { ...options, ...command.commandOptions }, + context: command.context, + })); + } + /** * Run one or more commands with specified extra options. * Returns the result of the last command run. @@ -178,57 +218,34 @@ export class CommandsManager { * @param options - to include in the commands run beyond * the commandOptions specified in the base. */ - public run( - toRun: Command | Commands | (Command | string)[] | string | undefined, - options?: Record - ): unknown { - if (!toRun) { - return; - } + public run(input: RunInput, options: Record = {}): unknown[] { + const commands = this.validate(input, options); - // 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)); + const results: unknown[] = []; + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const { commandName, commandOptions, context } = command; + results.push(this.runCommand(commandName, commandOptions, context)); } - if (commands.length === 0) { - console.log("Command isn't runnable", toRun); - return; - } + return results; + } - // Execute each command in the array - let result: unknown; - commands.forEach(command => { + /** Like run, but await each command before continuing */ + public async runAsync( + input: RunInput, + options: Record = {} + ): Promise { + const commands = this.validate(input, options); + + const results: unknown[] = []; + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; const { commandName, commandOptions, context } = command; - if (commandName) { - result = this.runCommand( - commandName, - { - ...commandOptions, - ...options, - }, - context - ); - } else { - if (typeof command === 'function') { - result = command(); - } else { - console.warn('No command name supplied in', toRun); - } - } - }); + results.push(await this.runCommand(commandName, commandOptions, context)); + } - return result; + return results; } } diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index 031d31d3ef7..5f01c13446e 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -33,6 +33,7 @@ import { PanelService, WorkflowStepsService, StudyPrefetcherService, + MultiMonitorService, } from './services'; import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService'; @@ -78,6 +79,7 @@ const OHIF = { ViewportGridService, HangingProtocolService, UserAuthenticationService, + MultiMonitorService, IWebApiDataSource, DicomMetadataStore, pubSubServiceInterface, @@ -119,6 +121,7 @@ export { DisplaySetMessage, DisplaySetMessageList, MeasurementService, + MultiMonitorService, ToolbarService, ViewportGridService, HangingProtocolService, diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index 85bf21f4fa4..558426d3270 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -372,8 +372,22 @@ export default class HangingProtocolService extends PubSubService { * for example, a prior view hanging protocol will NOT show the active study * specifically, but will show another study instead. */ - public setActiveStudyUID(activeStudyUID: string): void { + public setActiveStudyUID(activeStudyUID: string) { + if (!activeStudyUID || activeStudyUID === this.activeStudy?.StudyInstanceUID) { + return; + } this.activeStudy = this.studies.find(it => it.StudyInstanceUID === activeStudyUID); + return this.activeStudy; + } + + public hasStudyUID(studyUID: string): boolean { + return this.studies.some(it => it.StudyInstanceUID === studyUID); + } + + public addStudy(study) { + if (!this.hasStudyUID(study.StudyInstanceUID)) { + this.studies.push(study); + } } /** @@ -396,17 +410,22 @@ export default class HangingProtocolService extends PubSubService { public run({ studies, displaySets, activeStudy }, protocolId, options = {}) { this.studies = [...(studies || this.studies)]; this.displaySets = displaySets; - this.setActiveStudyUID((activeStudy || studies[0])?.StudyInstanceUID); + this.setActiveStudyUID( + activeStudy?.StudyInstanceUID || (activeStudy || this.studies[0])?.StudyInstanceUID + ); this.protocolEngine = new ProtocolEngine( this.getProtocols(), this.customAttributeRetrievalCallbacks ); + // Resets the full protocol status here. + this.protocol = null; + if (protocolId && typeof protocolId === 'string') { const protocol = this.getProtocolById(protocolId); this._setProtocol(protocol, options); - }else { + } else { const matchedProtocol = this.protocolEngine.run({ studies: this.studies, activeStudy, @@ -1201,6 +1220,7 @@ export default class HangingProtocolService extends PubSubService { viewportMatchDetails: Map; displaySetMatchDetails: Map; } { + this.activeStudy ||= this.studies[0]; let matchedViewports = 0; stageModel.viewports.forEach(viewport => { const viewportId = viewport.viewportOptions.viewportId; diff --git a/platform/core/src/services/MultiMonitorService.ts b/platform/core/src/services/MultiMonitorService.ts new file mode 100644 index 00000000000..810ec5ee3a9 --- /dev/null +++ b/platform/core/src/services/MultiMonitorService.ts @@ -0,0 +1,204 @@ +/** + * This service manages multiple monitors or windows. + */ +export class MultiMonitorService { + public readonly numberOfScreens: number; + private windowsConfig; + private screenConfig; + private launchWindows = []; + private basePath: string; + + public readonly screenNumber: number; + public readonly isMultimonitor: boolean; + + public static readonly SOURCE_SCREEN = { + id: 'source', + // This is the primary screen, so don't launch is separately, but use primary + launch: 'source', + screen: null, + location: { + screen: null, + width: 1, + height: 1, + left: 0, + top: 0, + }, + }; + + public static REGISTRATION = { + name: 'multiMonitorService', + create: ({ configuration, commandsManager }): MultiMonitorService => { + const service = new MultiMonitorService(configuration, commandsManager); + return service; + }, + }; + + constructor(configuration, commandsManager) { + const params = new URLSearchParams(window.location.search); + const screenNumber = params.get('screenNumber'); + const multimonitor = params.get('multimonitor'); + const testParams = { params, screenNumber, multimonitor }; + this.screenNumber = screenNumber ? Number(screenNumber) : -1; + this.commandsManager = commandsManager; + const windowAny = window as any; + windowAny.multimonitor ||= { + setLaunchWindows: this.setLaunchWindows, + launchWindows: this.launchWindows, + commandsManager, + }; + windowAny.multimonitor.commandsManager = commandsManager; + this.launchWindows = (window as any).multimonitor?.launchWindows || this.launchWindows; + if (this.screenNumber !== -1) { + this.launchWindows[this.screenNumber] = window; + } + windowAny.commandsManager = (...args) => configuration.commandsManager; + for (const windowsConfig of Array.isArray(configuration) ? configuration : []) { + if (windowsConfig.test(testParams)) { + this.isMultimonitor = true; + this.numberOfScreens = windowsConfig.screens.length; + this.windowsConfig = windowsConfig; + if (this.screenNumber === -1 || this.screenNumber === null) { + this.screenConfig = MultiMonitorService.SOURCE_SCREEN; + } else { + this.screenConfig = windowsConfig.screens[this.screenNumber]; + if (!this.screenConfig) { + throw new Error(`Screen ${screenNumber} not configured in ${this.windowsConfig}`); + } + window.name = this.screenConfig.id; + } + return; + } + this.numberOfScreens = 1; + this.isMultimonitor = false; + } + } + + public async run(screenDelta = 1, commands, options) { + const screenNumber = (this.screenNumber + (screenDelta ?? 1)) % this.numberOfScreens; + const otherWindow = await this.getWindow(screenNumber); + if (!otherWindow) { + console.warn('No multimonitor found for screen', screenNumber, commands); + return; + } + if (!otherWindow.multimonitor?.commandsManager) { + console.warn("Didn't find a commands manager to run in the other window", otherWindow); + return; + } + otherWindow.multimonitor.commandsManager.runAsync(commands, options); + } + + /** Sets the launch windows for later use, shared amongst all windows. */ + public setLaunchWindows = launchWindows => { + this.launchWindows = launchWindows; + (window as any).multimonitor.launchWindows = launchWindows; + }; + + public async launchWindow(studyUid: string, screenDelta = 1, hashParams = '') { + const forScreen = (this.screenNumber + screenDelta) % this.numberOfScreens; + return this.getWindow(forScreen, studyUid ? `StudyInstanceUIDs=${studyUid}${hashParams}` : ''); + } + + public async getWindow(screenNumber, hashParam?: string) { + if (screenNumber === this.screenNumber) { + return window; + } + if (this.launchWindows[screenNumber] && !this.launchWindows[screenNumber].closed) { + return this.launchWindows[screenNumber]; + } + return await this.createWindow(screenNumber, hashParam); + } + + /** + * Creates a new window showing the given url by default, or gets an existing + * window. + */ + public async createWindow(screenNumber, urlToUse?: string) { + if (screenNumber === this.screenNumber) { + return window; + } + const screenInfo = this.windowsConfig.screens[screenNumber]; + const screenDetails = await window.getScreenDetails?.(); + const screen = + (screenInfo.screen >= 0 && screenDetails.screens[screenInfo.screen]) || + screenDetails.currentScreen || + window.screen; + const { width = 1024, height = 1024, availLeft = 0, availTop = 0 } = screen || {}; + const newScreen = this.windowsConfig.screens[screenNumber]; + const { + width: widthPercent = 1, + height: heightPercent = 1, + top: topPercent = 0, + left: leftPercent = 0, + } = newScreen.location || {}; + + const useLeft = Math.round(availLeft + leftPercent * width); + const useTop = Math.round(availTop + topPercent * height); + const useWidth = Math.round(width * widthPercent); + const useHeight = Math.round(height * heightPercent); + + const baseFinalUrl = `${this.basePath}&screenNumber=${screenNumber}`; + const finalUrl = urlToUse ? `${baseFinalUrl}#${urlToUse}` : baseFinalUrl; + + const newId = newScreen.id; + const options = newScreen.options || ''; + const position = `screenX=${useLeft},screenY=${useTop},width=${useWidth},height=${useHeight},${options}`; + + let newWindow = window.open('', newId, position); + if (!newWindow?.location.href.startsWith(baseFinalUrl)) { + newWindow = window.open(finalUrl, newId, position); + } + if (!newWindow) { + console.warn('Unable to launch window', finalUrl, 'called', newId, 'at', position); + return; + } + + // Wait for the window to fully load + await new Promise(resolve => { + if (newWindow.document.readyState === 'complete') { + resolve(); + } else { + newWindow.addEventListener('load', () => resolve()); + } + }); + + this.launchWindows[screenNumber] = newWindow; + return newWindow; + } + + /** Launches all the windows using the initial configuration */ + public launchAll() { + for (let i = 0; i < this.numberOfScreens; i++) { + this.createWindow(i); + } + } + + /** + * Sets the base path to use for launching other windows, based on the + * original base path without hash values in order to preserve consistent + * URLs so that windows are refreshed on relaunch. + */ + public setBasePath() { + const url = new URL(window.location.href); + url.searchParams.delete('screenNumber'); + url.searchParams.delete('protocolId'); + url.searchParams.delete('launchAll'); + url.searchParams.set('multimonitor', url.searchParams.get('multimonitor') || 'split'); + url.hash = ''; + this.basePath = url.toString(); + } + + /** + * Try moving the screen to the correct location - this will only work with + * screens opened with openWindow containing no more than 1 tab. + */ + public async onModeEnter() { + this.setBasePath(); + + if ( + (this.isMultimonitor && this.screenNumber === -1) || + window.location.href.toLowerCase().indexOf('launchall') !== -1 + ) { + this.launchAll(); + } + } +} diff --git a/platform/core/src/services/ServicesManager.ts b/platform/core/src/services/ServicesManager.ts index a73ff9a45f9..8517455bc30 100644 --- a/platform/core/src/services/ServicesManager.ts +++ b/platform/core/src/services/ServicesManager.ts @@ -15,7 +15,7 @@ export default class ServicesManager { this.registeredServiceNames = []; } - setExtensionManager(extensionManager) { + public setExtensionManager(extensionManager) { this._extensionManager = extensionManager; } @@ -25,7 +25,7 @@ export default class ServicesManager { * @param {Object} service * @param {Object} configuration */ - registerService(service, configuration = {}) { + public registerService(service, configuration = {}) { if (!service) { log.warn('Attempting to register a null/undefined service. Exiting early.'); return; @@ -49,7 +49,6 @@ export default class ServicesManager { extensionManager: this._extensionManager, commandsManager: this._commandsManager, servicesManager: this, - extensionManager: this._extensionManager, }); if (service.altName) { // TODO - remove this registration @@ -70,7 +69,7 @@ export default class ServicesManager { * * @param {Object[]} services - Array of services */ - registerServices(services) { + public registerServices(services) { services.forEach(service => { const hasConfiguration = Array.isArray(service); diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index c2df4190e52..8ddd7946121 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -17,6 +17,7 @@ import CustomizationService from './CustomizationService'; import PanelService from './PanelService'; import WorkflowStepsService from './WorkflowStepsService'; import StudyPrefetcherService from './StudyPrefetcherService'; +import { MultiMonitorService } from './MultiMonitorService'; import type Services from '../types/Services'; @@ -31,6 +32,7 @@ export { UINotificationService, UIViewportDialogService, DicomMetadataStore, + MultiMonitorService, DisplaySetService, ToolbarService, ViewportGridService, diff --git a/platform/core/src/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts index 8d820b9569b..1ed544f1519 100644 --- a/platform/core/src/types/AppTypes.ts +++ b/platform/core/src/types/AppTypes.ts @@ -14,6 +14,7 @@ import PanelServiceType from '../services/PanelService'; import UIDialogServiceType from '../services/UIDialogService'; import UIViewportDialogServiceType from '../services/UIViewportDialogService'; import StudyPrefetcherServiceType from '../services/StudyPrefetcherService'; +import type { MultiMonitorService } from '../services/MultiMonitorService'; import ServicesManagerType from '../services/ServicesManager'; import CommandsManagerType from '../classes/CommandsManager'; @@ -55,6 +56,7 @@ declare global { export type UIViewportDialogService = UIViewportDialogServiceType; export type PanelService = PanelServiceType; export type StudyPrefetcherService = StudyPrefetcherServiceType; + export type MultiMonitorService; export interface Managers { servicesManager?: ServicesManager; @@ -78,6 +80,7 @@ declare global { uiViewportDialogService?: UIViewportDialogServiceType; panelService?: PanelServiceType; studyPrefetcherService?: StudyPrefetcherServiceType; + multiMonitorService?: MultiMonitorService; } export interface Config { diff --git a/platform/core/src/types/Services.ts b/platform/core/src/types/Services.ts index b8a514961a6..3812b80ef4b 100644 --- a/platform/core/src/types/Services.ts +++ b/platform/core/src/types/Services.ts @@ -13,6 +13,7 @@ import { PanelService, UIDialogService, UIViewportDialogService, + MultiMonitorService, } from '../services'; /** @@ -34,6 +35,7 @@ interface Services { uiDialogService?: UIDialogService; uiViewportDialogService?: UIViewportDialogService; panelService?: PanelService; + multiMonitorService?: MultiMonitorService; } export default Services; diff --git a/platform/docs/docs/platform/services/data/MultiMonitorService.md b/platform/docs/docs/platform/services/data/MultiMonitorService.md new file mode 100644 index 00000000000..eb74d28af80 --- /dev/null +++ b/platform/docs/docs/platform/services/data/MultiMonitorService.md @@ -0,0 +1,71 @@ +--- +sidebar_position: 5 +sidebar_label: Multi Monitor Service +--- + + +# Multi Monitor Service + +::: info + +We plan to enhance this service in the future. Currently, it offers a basic implementation of multi-monitor support, allowing you to manually open multiple windows on the same monitor. It is not yet a full multi-monitor solution! + +::: + + + + +The multi-monitor service provides detection, launch and communication support +for multiple monitors or windows/screens within a single monitor. + +:::info + +The multi-monitor service is currently applied via configuration file. + +```js +customizationService: ['@ohif/extension-default.customizationModule.multimonitor'], +``` + +::: + + + +## Configurations +The service supports two predefined configurations: + +1. **Split Screen (`multimonitor=split`)** + Splits the primary monitor into two windows. + +2. **Multi-Monitor (`multimonitor=2`)** + Opens windows across separate physical monitors. + +### Launch Methods +- Specify `&screenNumber=0` to designate the first window explicitly. +- Omit `screenNumber` to let the service handle window assignments dynamically. +- Use `launchAll` in the query parameters to launch all configured screens simultaneously. + +#### Example URLs: +- **Split Screen:** + `http://viewer.ohif.org/.....&multimonitor=split` + Splits the primary monitor into two windows when a study is viewed. + +- **Multi-Monitor with All Screens:** + `http://viewer.ohif.org/.....&multimonitor=2&screenNumber=0&launchAll` + Launches two monitors and opens all configured screens. + +--- + +## Behavior + +### Refresh, Close and Open +If you refresh the base/original window, then all the other windows will also +refresh. However, you can safely refresh any single other window, and on the next +command to the other windows, it will re-create the other window links without +losing content in the other windows. You can also close any other window and +it will be reopened the next time you try to call to it. + + +## Executing Commands +The MultiMonitorService adds the ability to run commands on other specified windows. +This allows opening up a study on another window without needing to refresh +it's contents. The command below shows an example of how this can be done: diff --git a/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx b/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx index 99ef692bb0c..33e68f680b3 100644 --- a/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx +++ b/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx @@ -5,17 +5,6 @@ import { StudyItem } from '../StudyItem'; import { StudyBrowserSort } from '../StudyBrowserSort'; import { StudyBrowserViewOptions } from '../StudyBrowserViewOptions'; -const getTrackedSeries = displaySets => { - let trackedSeries = 0; - displaySets.forEach(displaySet => { - if (displaySet.isTracked) { - trackedSeries++; - } - }); - - return trackedSeries; -}; - const noop = () => {}; const StudyBrowser = ({ @@ -31,7 +20,8 @@ const StudyBrowser = ({ servicesManager, showSettings, viewPresets, - onThumbnailContextMenu, + ThumbnailMenuItems, + StudyMenuItems, }: withAppTypes) => { const getTabContent = () => { const tabData = tabs.find(tab => tab.name === activeTabName); @@ -50,18 +40,17 @@ const StudyBrowser = ({ isExpanded={isExpanded} displaySets={displaySets} modalities={modalities} - trackedSeries={getTrackedSeries(displaySets)} isActive={isExpanded} - onClick={() => { - onClickStudy(studyInstanceUid); - }} + onClick={() => onClickStudy(studyInstanceUid)} onClickThumbnail={onClickThumbnail} onDoubleClickThumbnail={onDoubleClickThumbnail} onClickUntrack={onClickUntrack} activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs} data-cy="thumbnail-list" viewPreset={viewPreset} - onThumbnailContextMenu={onThumbnailContextMenu} + ThumbnailMenuItems={ThumbnailMenuItems} + StudyMenuItems={StudyMenuItems} + StudyInstanceUID={studyInstanceUid} /> ); @@ -142,6 +131,7 @@ StudyBrowser.propTypes = { ).isRequired, }) ), + StudyMenuItems: PropTypes.func, }; export { StudyBrowser }; diff --git a/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx b/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx index 493d73ac25f..4dcbb9c2e94 100644 --- a/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx +++ b/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx @@ -8,6 +8,8 @@ import { } from '../DropdownMenu/DropdownMenu'; export function StudyBrowserSort({ servicesManager }: withAppTypes) { + // Todo: this should not be here, no servicesManager should be in ui-next, only + // customization service const { customizationService, displaySetService } = servicesManager.services; const { values: sortFunctions } = customizationService.get('studyBrowser.sortFunctions'); diff --git a/platform/ui-next/src/components/StudyItem/StudyItem.tsx b/platform/ui-next/src/components/StudyItem/StudyItem.tsx index cd878cf01bd..4c9dee92255 100644 --- a/platform/ui-next/src/components/StudyItem/StudyItem.tsx +++ b/platform/ui-next/src/components/StudyItem/StudyItem.tsx @@ -19,7 +19,9 @@ const StudyItem = ({ onDoubleClickThumbnail, onClickUntrack, viewPreset = 'thumbnails', - onThumbnailContextMenu, + ThumbnailMenuItems, + StudyMenuItems, + StudyInstanceUID, }: withAppTypes) => { return ( - +
-
+
{date}
{description}
-
+
{modalities}
{numInstances}
+ {StudyMenuItems && ( +
+ +
+ )}
@@ -61,7 +68,7 @@ const StudyItem = ({ onThumbnailDoubleClick={onDoubleClickThumbnail} onClickUntrack={onClickUntrack} viewPreset={viewPreset} - onThumbnailContextMenu={onThumbnailContextMenu} + ThumbnailMenuItems={ThumbnailMenuItems} /> )} @@ -75,7 +82,6 @@ StudyItem.propTypes = { description: PropTypes.string, modalities: PropTypes.string.isRequired, numInstances: PropTypes.number.isRequired, - trackedSeries: PropTypes.number, isActive: PropTypes.bool, onClick: PropTypes.func.isRequired, isExpanded: PropTypes.bool, @@ -85,6 +91,8 @@ StudyItem.propTypes = { onDoubleClickThumbnail: PropTypes.func, onClickUntrack: PropTypes.func, viewPreset: PropTypes.string, + StudyMenuItems: PropTypes.func, + StudyInstanceUID: PropTypes.string, }; export { StudyItem }; diff --git a/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx b/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx index 1baba4cbd4f..40134a3a5fa 100644 --- a/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx +++ b/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx @@ -5,13 +5,6 @@ import { useDrag } from 'react-dnd'; import { Icons } from '../Icons'; import { DisplaySetMessageListTooltip } from '../DisplaySetMessageListTooltip'; import { TooltipTrigger, TooltipContent, Tooltip } from '../Tooltip'; -import { Button } from '../Button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../DropdownMenu'; /** * Display a thumbnail for a display set. @@ -34,12 +27,12 @@ const Thumbnail = ({ viewPreset = 'thumbnails', modality, isHydratedForDerivedDisplaySet = false, + isTracked = false, canReject = false, onReject = () => {}, - isTracked = false, thumbnailType = 'thumbnail', onClickUntrack = () => {}, - onThumbnailContextMenu, + ThumbnailMenuItems = () => {}, }: withAppTypes): React.ReactNode => { // TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as // this will still allow for "drag", even if there is no drop target for the @@ -134,44 +127,11 @@ const Thumbnail = ({
{/* bottom right */}
- - - - - - { - onThumbnailContextMenu('openDICOMTagViewer', { - displaySetInstanceUID, - }); - }} - className="gap-[6px]" - > - - Tag Browser - - {canReject && ( - { - onReject(); - }} - className="gap-[6px]" - > - - Delete Report - - )} - - +
@@ -243,7 +203,6 @@ const Thumbnail = ({ messages={messages} id={`display-set-tooltip-${displaySetInstanceUID}`} /> - {isTracked && ( @@ -271,41 +230,11 @@ const Thumbnail = ({ )} - - - - - - { - onThumbnailContextMenu('openDICOMTagViewer', { - displaySetInstanceUID, - }); - }} - className="gap-[6px]" - > - - Tag Browser - - {canReject && ( - { - onReject(); - }} - className="gap-[6px]" - > - - Delete Report - - )} - - + ); @@ -369,8 +298,6 @@ Thumbnail.propTypes = { viewPreset: PropTypes.string, modality: PropTypes.string, isHydratedForDerivedDisplaySet: PropTypes.bool, - canReject: PropTypes.bool, - onReject: PropTypes.func, isTracked: PropTypes.bool, onClickUntrack: PropTypes.func, countIcon: PropTypes.string, diff --git a/platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx b/platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx index 22dbd2c364a..0b2241d083c 100644 --- a/platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx +++ b/platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx @@ -10,7 +10,7 @@ const ThumbnailList = ({ onClickUntrack, activeDisplaySetInstanceUIDs = [], viewPreset, - onThumbnailContextMenu, + ThumbnailMenuItems, }: withAppTypes) => { return (
onClickUntrack(displaySetInstanceUID)} isHydratedForDerivedDisplaySet={isHydratedForDerivedDisplaySet} - canReject={canReject} - onReject={onReject} - onThumbnailContextMenu={onThumbnailContextMenu} + ThumbnailMenuItems={ThumbnailMenuItems} /> ); } diff --git a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx index 93bc0b137c7..e13bf4ca3d6 100644 --- a/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx +++ b/platform/ui/src/components/StudyBrowser/StudyBrowser.tsx @@ -34,6 +34,7 @@ const StudyBrowser = ({ onClickThumbnail = noop, onDoubleClickThumbnail = noop, onClickUntrack = noop, + onClickLaunch, activeDisplaySetInstanceUIDs, servicesManager, }: withAppTypes) => { @@ -60,6 +61,7 @@ const StudyBrowser = ({ onClick={() => { onClickStudy(studyInstanceUid); }} + onClickLaunch={onClickLaunch?.bind(null, studyInstanceUid)} data-cy="thumbnail-list" /> {isExpanded && displaySets && ( diff --git a/platform/ui/src/components/StudyItem/StudyItem.tsx b/platform/ui/src/components/StudyItem/StudyItem.tsx index f14fe7c2fa5..803a7949e9a 100644 --- a/platform/ui/src/components/StudyItem/StudyItem.tsx +++ b/platform/ui/src/components/StudyItem/StudyItem.tsx @@ -15,8 +15,21 @@ const StudyItem = ({ trackedSeries, isActive, onClick, + onClickLaunch, }) => { const { t } = useTranslation('StudyItem'); + + const onSetActive = evt => { + evt.stopPropagation(); + onClickLaunch(0); + return false; + }; + const onLaunchWindow = evt => { + onClickLaunch(1); + evt.stopPropagation(); + return false; + }; + return (
{numInstances}
+ {!!onClickLaunch && ( +
+ + +
+ )}
{modalities}