feat(multimonitor): Add simple multi-monitor support to open another study(#4178)

This commit is contained in:
Bill Wallace 2025-01-13 10:49:08 -05:00 committed by GitHub
parent a4a6de07f4
commit 07c628e689
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1476 additions and 367 deletions

View File

@ -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;

View File

@ -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 (
<DropdownMenuContent
hideWhenDetached
align="start"
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{items?.map(item =>
menuContent.content({
key: item.id,
item,
commandsManager,
servicesManager,
...props,
})
)}
</DropdownMenuContent>
);
};
/**
* 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden group-hover:inline-flex data-[state=open]:inline-flex"
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icons.More />
</Button>
</DropdownMenuTrigger>
{getMenuItems({
...props,
commandsManager: commandsManager,
servicesManager: servicesManager,
items,
})}
</DropdownMenu>
);
}
return BoundMoreDropdownMenu;
}

View File

@ -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 => {

View File

@ -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',
})}
/>
</>
);

View File

@ -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),

View File

@ -13,7 +13,7 @@ function requestDisplaySetCreationForStudy(
return;
}
dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient });
return dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient });
}
export default requestDisplaySetCreationForStudy;

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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 (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-[6px]">
{item.iconName && (
<Icons.ByName
name={item.iconName}
className="-ml-1"
/>
)}
{item.label}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{item.items.map(subItem => this.content({ ...props, item: subItem }))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
// Regular menu item
const isDisabled = item.selector && !item.selector({ servicesManager });
return (
<DropdownMenuItem
disabled={isDisabled}
onSelect={() => {
commandsManager.runAsync(item.commands, {
...item.commandOptions,
...rest,
});
}}
className="gap-[6px]"
>
{item.iconName && (
<Icons.ByName
name={item.iconName}
className="-ml-1"
/>
)}
{item.label}
</DropdownMenuItem>
);
},
},
],
},
];

View File

@ -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,
},
];
}

View File

@ -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;

View File

@ -7,7 +7,7 @@ import {
LCCPrior,
RMLOPrior,
LMLOPrior,
} from './mammoDisplaySetSelector';
} from './utils/mammoDisplaySetSelector';
const rightDisplayArea = {
storeAsInitialCamera: true,

View File

@ -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,

View File

@ -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,
},
},
];

View File

@ -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,
},
},
];

View File

@ -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;

View File

@ -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,
};

View File

@ -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;

View File

@ -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(

View File

@ -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;

View File

@ -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: {

View File

@ -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',

View File

@ -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,

View File

@ -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;
}

View File

@ -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 };

View File

@ -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) {

View File

@ -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,

View File

@ -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;
}
}
}

View File

@ -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<string, unknown> = {}): 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<string, unknown>
): unknown {
if (!toRun) {
return;
}
public run(input: RunInput, options: Record<string, unknown> = {}): 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));
}
if (commands.length === 0) {
console.log("Command isn't runnable", toRun);
return;
}
// Execute each command in the array
let result: unknown;
commands.forEach(command => {
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(this.runCommand(commandName, commandOptions, context));
}
return result;
return results;
}
/** Like run, but await each command before continuing */
public async runAsync(
input: RunInput,
options: Record<string, unknown> = {}
): Promise<unknown[]> {
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;
results.push(await this.runCommand(commandName, commandOptions, context));
}
return results;
}
}

View File

@ -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,

View File

@ -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<string, HangingProtocol.ViewportMatchDetails>;
displaySetMatchDetails: Map<string, HangingProtocol.DisplaySetMatchDetails>;
} {
this.activeStudy ||= this.studies[0];
let matchedViewports = 0;
stageModel.viewports.forEach(viewport => {
const viewportId = viewport.viewportOptions.viewportId;

View File

@ -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<void>(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();
}
}
}

View File

@ -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);

View File

@ -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,

View File

@ -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 {

View File

@ -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;

View File

@ -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:

View File

@ -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}
/>
</React.Fragment>
);
@ -142,6 +131,7 @@ StudyBrowser.propTypes = {
).isRequired,
})
),
StudyMenuItems: PropTypes.func,
};
export { StudyBrowser };

View File

@ -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');

View File

@ -19,7 +19,9 @@ const StudyItem = ({
onDoubleClickThumbnail,
onClickUntrack,
viewPreset = 'thumbnails',
onThumbnailContextMenu,
ThumbnailMenuItems,
StudyMenuItems,
StudyInstanceUID,
}: withAppTypes) => {
return (
<Accordion
@ -32,19 +34,24 @@ const StudyItem = ({
defaultValue={isActive ? 'study-item' : undefined}
>
<AccordionItem value="study-item">
<AccordionTrigger className={classnames('hover:bg-accent bg-popover rounded')}>
<AccordionTrigger className={classnames('hover:bg-accent bg-popover group rounded')}>
<div className="flex h-[40px] flex-1 flex-row">
<div className="flex w-full flex-row items-center justify-between">
<div className="flex w-full flex-row items-center">
<div className="flex flex-col items-start text-[13px]">
<div className="text-white">{date}</div>
<div className="text-muted-foreground h-[18px] max-w-[160px] overflow-hidden truncate whitespace-nowrap">
{description}
</div>
</div>
<div className="text-muted-foreground mr-2 flex flex-col items-end text-[12px]">
<div className="text-muted-foreground ml-auto flex flex-col items-end text-[12px]">
<div className="max-w-[150px] overflow-hidden text-ellipsis">{modalities}</div>
<div>{numInstances}</div>
</div>
{StudyMenuItems && (
<div className="ml-2 flex items-center">
<StudyMenuItems StudyInstanceUID={StudyInstanceUID} />
</div>
)}
</div>
</div>
</AccordionTrigger>
@ -61,7 +68,7 @@ const StudyItem = ({
onThumbnailDoubleClick={onDoubleClickThumbnail}
onClickUntrack={onClickUntrack}
viewPreset={viewPreset}
onThumbnailContextMenu={onThumbnailContextMenu}
ThumbnailMenuItems={ThumbnailMenuItems}
/>
)}
</AccordionContent>
@ -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 };

View File

@ -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 = ({
</div>
{/* bottom right */}
<div className="absolute bottom-0 right-0 flex items-center gap-[4px] p-[4px]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden group-hover:inline-flex data-[state=open]:inline-flex"
>
<Icons.More />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
hideWhenDetached
align="start"
>
<DropdownMenuItem
onSelect={() => {
onThumbnailContextMenu('openDICOMTagViewer', {
displaySetInstanceUID,
});
}}
className="gap-[6px]"
>
<Icons.DicomTagBrowser />
Tag Browser
</DropdownMenuItem>
{canReject && (
<DropdownMenuItem
onSelect={() => {
onReject();
}}
className="gap-[6px]"
>
<Icons.Trash className="h-5 w-5 text-red-500" />
Delete Report
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<ThumbnailMenuItems
displaySetInstanceUID={displaySetInstanceUID}
canReject={canReject}
onReject={onReject}
/>
</div>
</div>
</div>
@ -243,7 +203,6 @@ const Thumbnail = ({
messages={messages}
id={`display-set-tooltip-${displaySetInstanceUID}`}
/>
{isTracked && (
<Tooltip>
<TooltipTrigger>
@ -271,41 +230,11 @@ const Thumbnail = ({
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden group-hover:inline-flex data-[state=open]:inline-flex"
>
<Icons.More />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent hideWhenDetached>
<DropdownMenuItem
onSelect={() => {
onThumbnailContextMenu('openDICOMTagViewer', {
displaySetInstanceUID,
});
}}
className="gap-[6px]"
>
<Icons.DicomTagBrowser />
Tag Browser
</DropdownMenuItem>
{canReject && (
<DropdownMenuItem
onSelect={() => {
onReject();
}}
className="gap-[6px]"
>
<Icons.Trash className="h-5 w-5 text-red-500" />
Delete Report
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<ThumbnailMenuItems
displaySetInstanceUID={displaySetInstanceUID}
canReject={canReject}
onReject={onReject}
/>
</div>
</div>
);
@ -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,

View File

@ -10,7 +10,7 @@ const ThumbnailList = ({
onClickUntrack,
activeDisplaySetInstanceUIDs = [],
viewPreset,
onThumbnailContextMenu,
ThumbnailMenuItems,
}: withAppTypes) => {
return (
<div
@ -34,9 +34,9 @@ const ThumbnailList = ({
modality,
componentType,
countIcon,
isTracked,
canReject,
onReject,
isTracked,
imageSrc,
messages,
imageAltText,
@ -56,6 +56,8 @@ const ThumbnailList = ({
imageAltText={imageAltText}
messages={messages}
isActive={isActive}
canReject={canReject}
onReject={onReject}
modality={modality}
viewPreset={componentType === 'thumbnailNoImage' ? 'list' : viewPreset}
thumbnailType={componentType}
@ -65,9 +67,7 @@ const ThumbnailList = ({
loadingProgress={loadingProgress}
onClickUntrack={() => onClickUntrack(displaySetInstanceUID)}
isHydratedForDerivedDisplaySet={isHydratedForDerivedDisplaySet}
canReject={canReject}
onReject={onReject}
onThumbnailContextMenu={onThumbnailContextMenu}
ThumbnailMenuItems={ThumbnailMenuItems}
/>
);
}

View File

@ -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 && (

View File

@ -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 (
<div
className={classnames(
@ -35,6 +48,20 @@ const StudyItem = ({
<Icons.GroupLayers className="mx-2 w-4 text-blue-300" />
{numInstances}
</div>
{!!onClickLaunch && (
<div className="items-right flex flex-row text-base text-blue-300">
<Icon
name="icon-play"
className="mx-2 w-4 text-blue-300"
onClick={onSetActive}
/>
<Icon
name="launch-arrow"
className="mx-2 w-4 text-blue-300"
onClick={onLaunchWindow}
/>
</div>
)}
</div>
<div className="flex flex-row items-center py-1">
<div className="text-l flex items-center pr-5 text-blue-300">{modalities}</div>