feat(multimonitor): Add simple multi-monitor support to open another study(#4178)
This commit is contained in:
1 parent
a4a6de07f4
commit
07c628e689
49 files changed
+1476
-367
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user