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

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