feat(multimonitor): Add simple multi-monitor support to open another study(#4178)
This commit is contained in:
parent
a4a6de07f4
commit
07c628e689
@ -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;
|
||||
|
||||
91
extensions/default/src/Components/MoreDropdownMenu.tsx
Normal file
91
extensions/default/src/Components/MoreDropdownMenu.tsx
Normal 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;
|
||||
}
|
||||
@ -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 => {
|
||||
|
||||
@ -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',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -13,7 +13,7 @@ function requestDisplaySetCreationForStudy(
|
||||
return;
|
||||
}
|
||||
|
||||
dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient });
|
||||
return dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient });
|
||||
}
|
||||
|
||||
export default requestDisplaySetCreationForStudy;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
LCCPrior,
|
||||
RMLOPrior,
|
||||
LMLOPrior,
|
||||
} from './mammoDisplaySetSelector';
|
||||
} from './utils/mammoDisplaySetSelector';
|
||||
|
||||
const rightDisplayArea = {
|
||||
storeAsInitialCamera: true,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -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;
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
26
platform/app/src/utils/preserveQueryParameters.ts
Normal file
26
platform/app/src/utils/preserveQueryParameters.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
204
platform/core/src/services/MultiMonitorService.ts
Normal file
204
platform/core/src/services/MultiMonitorService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
@ -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 };
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user