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

This commit is contained in:
Bill Wallace authored and GitHub committed 2025-01-13 10:49:08 -05:00
1 parent a4a6de07f4
commit 07c628e689
49 files changed
+1476 -367

No files matched your search

+61
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: {
+61
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',
+2
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,
+7 -1
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;
}
+3 -4
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 };
@@ -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) {
+1 -2
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,
@@ -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;
}
}
}
+65 -48
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;
}
}
+3
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,
@@ -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;
@@ -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);
+2
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,
+3
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 {
+2
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;
@@ -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>