feat(toolbar): new Toolbar to enable reactive state synchronization (#3983)

This commit is contained in:
Alireza authored and GitHub committed 2024-03-27 16:01:32 -04:00
1 parent 79d5c36bda
commit 566b25a544
155 files changed
+6311 -6577

No files matched your search

@@ -8,7 +8,6 @@ describe('CommandsManager', () => {
contextName = 'VTK',
command = {
commandFn: jest.fn().mockReturnValue(true),
storeContexts: ['viewers'],
options: { passMeToCommandFn: ':wave:' },
},
commandsManagerConfig = {
@@ -139,7 +138,6 @@ describe('CommandsManager', () => {
it('Logs a warning if command definition does not have a commandFn', () => {
const commandWithNoCommmandFn = {
commandFn: undefined,
storeContexts: [],
options: {},
};
+26 -10
View File
@@ -1,5 +1,5 @@
import log from '../log.js';
import { Command, Commands } from '../types/Command';
import { Command, Commands, ComplexCommand } from '../types/Command';
/**
* The definition of a command
@@ -167,28 +167,44 @@ export class CommandsManager {
* Run one or more commands with specified extra options.
* Returns the result of the last command run.
*
* @param toRun - A specification of one or more commands
* @param toRun - A specification of one or more commands,
* typically an object of { commandName, commandOptions, context }
* or an array of such objects. It can also be a single commandName as string
* if no options are needed.
* @param options - to include in the commands run beyond
* the commandOptions specified in the base.
*/
public run(
toRun: Command | Commands | Command[] | undefined,
toRun: Command | Commands | Command[] | string | undefined,
options?: Record<string, unknown>
): unknown {
if (!toRun) {
return;
}
const commands =
(Array.isArray(toRun) && toRun) ||
((toRun as Command).commandName && [toRun]) ||
(Array.isArray((toRun as Commands).commands) && (toRun as Commands).commands);
if (!commands) {
// 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;
}
let result;
(commands as Command[]).forEach(({ commandName, commandOptions, context }) => {
// Execute each command in the array
let result: unknown;
commands.forEach(({ commandName, commandOptions, context }) => {
if (commandName) {
result = this.runCommand(
commandName,
@@ -1,5 +1,4 @@
import objectHash from 'object-hash';
import log from '../log.js';
import { hotkeys } from '../utils';
import isequal from 'lodash.isequal';
import Hotkey from './Hotkey';
@@ -276,10 +275,3 @@ export class HotkeysManager {
}
export default HotkeysManager;
// Commands Contexts:
// --> Name and Priority
// GLOBAL: 0
// VIEWER::CORNERSTONE: 1
// VIEWER::VTK: 1
@@ -247,7 +247,6 @@ describe('ExtensionManager.ts', () => {
definitions: {
exampleDefinition: {
commandFn: () => {},
storeContexts: [],
options: {},
},
},
+105 -40
View File
@@ -45,6 +45,7 @@ export interface Extension {
getUtilityModule?: (p: ExtensionParams) => unknown;
getCustomizationModule?: (p: ExtensionParams) => unknown;
getSopClassHandlerModule?: (p: ExtensionParams) => unknown;
getToolbarModule?: (p: ExtensionParams) => unknown;
onModeEnter?: () => void;
onModeExit?: () => void;
}
@@ -65,9 +66,24 @@ export default class ExtensionManager extends PubSubService {
ACTIVE_DATA_SOURCE_CHANGED: 'event::activedatasourcechanged',
};
public static readonly MODULE_TYPES = MODULE_TYPES;
private _commandsManager: CommandsManager;
private _servicesManager: ServicesManager;
private _hotkeysManager: HotkeysManager;
private modulesMap: Record<string, unknown>;
private modules: Record<string, any[]>;
private registeredExtensionIds: string[];
private moduleTypeNames: string[];
private _appConfig: any;
private _extensionLifeCycleHooks: {
onModeEnter: Record<string, any>;
onModeExit: Record<string, any>;
};
private dataSourceMap: Record<string, any>;
private dataSourceDefs: Record<string, any>;
private defaultDataSourceName: string;
private activeDataSource: string;
constructor({
commandsManager,
@@ -265,57 +281,74 @@ export default class ExtensionManager extends PubSubService {
configuration
);
if (extensionModule) {
switch (moduleType) {
case MODULE_TYPES.COMMANDS:
this._initCommandsModule(extensionModule);
break;
case MODULE_TYPES.DATA_SOURCE:
this._initDataSourcesModule(extensionModule, extensionId, dataSources);
break;
case MODULE_TYPES.HANGING_PROTOCOL:
this._initHangingProtocolsModule(extensionModule, extensionId);
case MODULE_TYPES.TOOLBAR:
case MODULE_TYPES.VIEWPORT:
case MODULE_TYPES.PANEL:
case MODULE_TYPES.SOP_CLASS_HANDLER:
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
case MODULE_TYPES.CUSTOMIZATION:
case MODULE_TYPES.STATE_SYNC:
case MODULE_TYPES.UTILITY:
// Default for most extension points,
// Just adds each entry ready for consumption by mode.
extensionModule.forEach(element => {
if (!element.name) {
throw new Error(
`Extension ID ${extensionId} module ${moduleType} element has no name`
);
}
const id = `${extensionId}.${moduleType}.${element.name}`;
element.id = id;
this.modulesMap[id] = element;
});
break;
default:
throw new Error(`Module type invalid: ${moduleType}`);
}
this.modules[moduleType].push({
extensionId,
module: extensionModule,
});
if (!extensionModule) {
return;
}
switch (moduleType) {
case MODULE_TYPES.COMMANDS:
this._initCommandsModule(extensionModule);
break;
case MODULE_TYPES.DATA_SOURCE:
this._initDataSourcesModule(extensionModule, extensionId, dataSources);
break;
case MODULE_TYPES.HANGING_PROTOCOL:
this._initHangingProtocolsModule(extensionModule, extensionId);
break;
case MODULE_TYPES.PANEL:
this._initPanelModule(extensionModule, extensionId);
break;
case MODULE_TYPES.TOOLBAR:
this._initToolbarModule(extensionModule, extensionId);
break;
case MODULE_TYPES.VIEWPORT:
case MODULE_TYPES.SOP_CLASS_HANDLER:
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
case MODULE_TYPES.CUSTOMIZATION:
case MODULE_TYPES.STATE_SYNC:
case MODULE_TYPES.UTILITY:
this.processExtensionModule(extensionModule, extensionId, moduleType);
break;
default:
throw new Error(`Module type invalid: ${moduleType}`);
}
this.modules[moduleType].push({
extensionId,
module: extensionModule,
});
});
// Track extension registration
this.registeredExtensionIds.push(extensionId);
};
/**
* Retrieves the module entry associated with the given string entry
* @param stringEntry - The string entry to retrieve the module entry for which is
* in the format of `${extensionId}.${moduleType}.${moduleName}`
* @returns The module entry associated with the given string entry.
*/
getModuleEntry = stringEntry => {
return this.modulesMap[stringEntry];
};
/**
* Retrieves all modules of a given type for all registered extensions.
*
* @param moduleType - The type of modules to retrieve.
* @returns An array of modules of the specified type.
*/
getModulesByType = (moduleType: string) => {
return this.modules[moduleType];
};
getDataSources = dataSourceName => {
if (dataSourceName === undefined) {
// Default to the activeDataSource
@@ -402,6 +435,38 @@ export default class ExtensionManager extends PubSubService {
});
};
_initPanelModule = (extensionModule, extensionId) => {
this.processExtensionModule(extensionModule, extensionId, MODULE_TYPES.PANEL);
};
_initToolbarModule = (extensionModule, extensionId) => {
// check if the toolbar module has a handler function for evaluation of
// the toolbar button state
const { toolbarService } = this._servicesManager.services;
extensionModule.forEach(toolbarButton => {
if (toolbarButton.evaluate) {
toolbarService.registerEvaluateFunction(toolbarButton.name, toolbarButton.evaluate);
}
});
};
/**
* Processes an extension module.
* @param extensionModule - The extension module to process.
* @param extensionId - The ID of the extension.
* @param moduleType - The type of the module.
*/
private processExtensionModule(extensionModule, extensionId: string, moduleType: string) {
extensionModule.forEach(element => {
if (!element.name) {
throw new Error(`Extension ID ${extensionId} module ${moduleType} element has no name`);
}
const id = `${extensionId}.${moduleType}.${element.name}`;
element.id = id;
this.modulesMap[id] = element;
});
}
/**
* Adds the given data source and optionally sets it as the active data source.
* The method does this by first creating the data source.
+58
View File
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react';
export function useToolbar({ servicesManager, buttonSection = 'primary' }) {
const { toolbarService, viewportGridService } = servicesManager.services;
const { EVENTS } = toolbarService;
const [toolbarButtons, setToolbarButtons] = useState(
toolbarService.getButtonSection(buttonSection)
);
// Callback function for handling toolbar interactions
const onInteraction = useCallback(
args => {
const viewportId = viewportGridService.getActiveViewportId();
const refreshProps = {
viewportId,
};
toolbarService.recordInteraction(args, {
refreshProps,
});
},
[toolbarService, viewportGridService]
);
// Effect to handle toolbar modification events
useEffect(() => {
const handleToolbarModified = () => {
setToolbarButtons(toolbarService.getButtonSection(buttonSection));
};
const subs = [EVENTS.TOOL_BAR_MODIFIED, EVENTS.TOOL_BAR_STATE_MODIFIED].map(event => {
return toolbarService.subscribe(event, handleToolbarModified);
});
return () => {
subs.forEach(sub => sub.unsubscribe());
};
}, [toolbarService]);
// Effect to handle active viewportId change event
useEffect(() => {
const events = [
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
viewportGridService.EVENTS.VIEWPORTS_READY,
];
const subscriptions = events.map(event => {
return viewportGridService.subscribe(event, ({ viewportId }) => {
viewportId = viewportId || viewportGridService.getActiveViewportId();
toolbarService.refreshToolbarState({ viewportId });
});
});
return () => subscriptions.forEach(sub => sub.unsubscribe());
}, [viewportGridService, toolbarService]);
return { toolbarButtons, onInteraction };
}
+1
View File
@@ -45,6 +45,7 @@ describe('Top level exports', () => {
'pubSubServiceInterface',
'PubSubService',
'PanelService',
'useToolbar',
].sort();
const exports = Object.keys(OHIF).sort();
+3
View File
@@ -12,6 +12,7 @@ import utils from './utils';
import defaults from './defaults';
import * as Types from './types';
import * as Enums from './enums';
import { useToolbar } from './hooks/useToolbar';
import {
CineService,
@@ -81,6 +82,7 @@ const OHIF = {
pubSubServiceInterface,
PubSubService,
PanelService,
useToolbar,
};
export {
@@ -124,6 +126,7 @@ export {
Enums,
Types,
PanelService,
useToolbar,
};
export { OHIF };
@@ -1,82 +1,80 @@
const name = 'CineService';
import { PubSubService } from '../_shared/pubSubServiceInterface';
const publicAPI = {
name,
getState: _getState,
setCine: _setCine,
setIsCineEnabled: _setIsCineEnabled,
playClip: _playClip,
stopClip: _stopClip,
onModeExit: _onModeExit,
setServiceImplementation,
};
class CineService extends PubSubService {
public static readonly EVENTS = {
CINE_STATE_CHANGED: 'event::cineStateChanged',
};
const serviceImplementation = {
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
_setCine: () => console.warn('setCine() NOT IMPLEMENTED'),
_playClip: () => console.warn('playClip() NOT IMPLEMENTED'),
_stopClip: () => console.warn('stopClip() NOT IMPLEMENTED'),
_setIsCineEnabled: () => console.warn('setIsCineEnabled() NOT IMPLEMENTED'),
};
function _getState() {
return serviceImplementation._getState();
}
function _setCine({ id, frameRate, isPlaying }) {
return serviceImplementation._setCine({ id, frameRate, isPlaying });
}
function _setIsCineEnabled(isCineEnabled) {
return serviceImplementation._setIsCineEnabled(isCineEnabled);
}
function _playClip(element, playClipOptions) {
return serviceImplementation._playClip(element, playClipOptions);
}
function _stopClip(element) {
return serviceImplementation._stopClip(element);
}
function _onModeExit() {
_setIsCineEnabled(false);
}
function setServiceImplementation({
getState: getStateImplementation,
setCine: setCineImplementation,
setIsCineEnabled: setIsCineEnabledImplementation,
playClip: playClipImplementation,
stopClip: stopClipImplementation,
}) {
if (getStateImplementation) {
serviceImplementation._getState = getStateImplementation;
}
if (setCineImplementation) {
serviceImplementation._setCine = setCineImplementation;
}
if (setIsCineEnabledImplementation) {
serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
}
if (playClipImplementation) {
serviceImplementation._playClip = playClipImplementation;
}
if (stopClipImplementation) {
serviceImplementation._stopClip = stopClipImplementation;
}
}
const CineService = {
REGISTRATION: {
altName: name,
public static REGISTRATION = {
name: 'cineService',
altName: 'CineService',
create: ({ configuration = {} }) => {
return publicAPI;
return new CineService();
},
},
};
};
serviceImplementation = {};
constructor() {
super(CineService.EVENTS);
this.serviceImplementation = {};
}
public getState() {
return this.serviceImplementation._getState();
}
public setCine({ id, frameRate, isPlaying }) {
return this.serviceImplementation._setCine({ id, frameRate, isPlaying });
}
public setIsCineEnabled(isCineEnabled) {
this.serviceImplementation._setIsCineEnabled(isCineEnabled);
// Todo: for some reason i need to do this setTimeout since the
// reducer state does not get updated right away and if we publish the
// event and we use the cineService.getState() it will return the old state
setTimeout(() => {
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, isCineEnabled);
}, 0);
}
public playClip(element, playClipOptions) {
return this.serviceImplementation._playClip(element, playClipOptions);
}
public stopClip(element) {
return this.serviceImplementation._stopClip(element);
}
public _onModeExit() {
this.setIsCineEnabled(false);
}
public setServiceImplementation({
getState: getStateImplementation,
setCine: setCineImplementation,
setIsCineEnabled: setIsCineEnabledImplementation,
playClip: playClipImplementation,
stopClip: stopClipImplementation,
}) {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
if (setCineImplementation) {
this.serviceImplementation._setCine = setCineImplementation;
}
if (setIsCineEnabledImplementation) {
this.serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
}
if (playClipImplementation) {
this.serviceImplementation._playClip = playClipImplementation;
}
if (stopClipImplementation) {
this.serviceImplementation._stopClip = stopClipImplementation;
}
}
}
export default CineService;
@@ -135,15 +135,15 @@ export default class CustomizationService extends PubSubService {
return this.getModeCustomization(customizationId, defaultValue);
}
/** Mode customizations are changes to the behaviour of the extensions
/** Mode customizations are changes to the behavior of the extensions
* when running in a given mode. Reset clears mode customizations.
* Note that global customizations over-ride mode customizations.
* @param defautlValue to return if no customization specified.
* @param defaultValue to return if no customization specified.
*/
public getModeCustomization(
customizationId: string,
defaultValue?: Customization
): Customization | void {
): Customization {
const customization =
this.globalCustomizations[customizationId] ??
this.modeCustomizations[customizationId] ??
@@ -113,7 +113,7 @@ class MeasurementService extends PubSubService {
public readonly VALUE_TYPES = VALUE_TYPES;
private measurements = new Map();
private unmappedMeasurements = new Set();
private unmappedMeasurements = new Map();
constructor() {
super(EVENTS);
@@ -485,7 +485,15 @@ class MeasurementService extends PubSubService {
measurement = toMeasurementSchema(sourceAnnotationDetail);
measurement.source = source;
} catch (error) {
this.unmappedMeasurements.add(sourceAnnotationDetail.uid);
// Todo: handle other
this.unmappedMeasurements.set(sourceAnnotationDetail.uid, {
...sourceAnnotationDetail,
source: {
name: source.name,
version: source.version,
uid: source.uid,
},
});
console.log('Failed to map', error);
throw new Error(
@@ -567,10 +575,9 @@ class MeasurementService extends PubSubService {
}
clearMeasurements() {
this.unmappedMeasurements.clear();
// Make a copy of the measurements
const measurements = [...this.measurements.values()];
const measurements = [...this.measurements.values(), ...this.unmappedMeasurements.values()];
this.unmappedMeasurements.clear();
this.measurements.clear();
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
}
@@ -1,9 +1,13 @@
import log from './../log.js';
import Services from '../types/Services';
import CommandsManager from '../classes/CommandsManager';
import ExtensionManager from '../extensions/ExtensionManager';
export default class ServicesManager {
public services: Services = {};
public registeredServiceNames: string[] = [];
private _commandsManager: CommandsManager;
private _extensionManager: ExtensionManager;
constructor(commandsManager: CommandsManager) {
this._commandsManager = commandsManager;
@@ -11,6 +15,10 @@ export default class ServicesManager {
this.registeredServiceNames = [];
}
setExtensionManager(extensionManager) {
this._extensionManager = extensionManager;
}
/**
* Registers a new service.
*
@@ -40,6 +48,7 @@ export default class ServicesManager {
configuration,
commandsManager: this._commandsManager,
servicesManager: this,
extensionManager: this._extensionManager,
});
if (service.altName) {
console.log('Registering old name', service.altName);
@@ -1,109 +1,79 @@
import merge from 'lodash.merge';
import { CommandsManager } from '../../classes';
import { ExtensionManager } from '../../extensions';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { RunCommand, Commands } from '../../types/Command';
import type { RunCommand } from '../../types/Command';
import ServicesManager from '../ServicesManager';
import { Button, ButtonProps, EvaluateFunction, EvaluatePublic, NestedButtonProps } from './types';
const EVENTS = {
TOOL_BAR_MODIFIED: 'event::toolBarService:toolBarModified',
TOOL_BAR_STATE_MODIFIED: 'event::toolBarService:toolBarStateModified',
};
export type ButtonListeners = Record<string, RunCommand>;
export interface ButtonProps {
primary?: Button;
secondary?: Button;
items?: Button[];
}
export interface Button extends Commands {
id: string;
icon?: string;
label?: string;
type?: string;
tooltip?: string;
isActive?: boolean;
listeners?: ButtonListeners;
props?: ButtonProps;
}
export interface ExtraButtonOptions {
listeners?: ButtonListeners;
isActive?: boolean;
}
export default class ToolbarService extends PubSubService {
public static REGISTRATION = {
name: 'toolbarService',
// Note the old name is ToolBarService, with an upper B
altName: 'ToolBarService',
create: ({ commandsManager }) => {
return new ToolbarService(commandsManager);
create: ({ commandsManager, extensionManager, servicesManager }) => {
return new ToolbarService(commandsManager, extensionManager, servicesManager);
},
};
public static _createButton(
type: string,
id: string,
icon: string,
label: string,
commands: Command | Commands,
tooltip?: string,
extraOptions?: ExtraButtonOptions
): Button {
public static createButton(options: {
id: string;
label: string;
commands: RunCommand;
icon?: string;
tooltip?: string;
evaluate?: EvaluatePublic;
listeners?: Record<string, RunCommand>;
}): ButtonProps {
const { id, icon, label, commands, tooltip, evaluate, listeners } = options;
return {
id,
icon,
label,
type,
commands,
tooltip,
...extraOptions,
tooltip: tooltip || label,
evaluate,
listeners,
};
}
public static _createActionButton = ToolbarService._createButton.bind(null, 'action');
public static _createToggleButton = ToolbarService._createButton.bind(null, 'toggle');
public static _createToolButton = ToolbarService._createButton.bind(null, 'tool');
buttons: Record<string, Button> = {};
state: {
primaryToolId: string;
toggles: Record<string, boolean>;
groups: Record<string, unknown>;
} = { primaryToolId: '', toggles: {}, groups: {} };
buttonSections: Record<string, unknown> = {
/**
* primary: ['Zoom', 'Wwwc'],
* secondary: ['Length', 'RectangleRoi']
*/
// all buttons in the toolbar with their props
buttons: Record<string, Button>;
// the buttons in the toolbar, grouped by section, with their ids
buttonSections: Record<string, string[]>;
} = {
buttons: {},
buttonSections: {},
};
_commandsManager: CommandsManager;
extensionManager: ExtensionManager;
_extensionManager: ExtensionManager;
_servicesManager: ServicesManager;
_evaluateFunction: Record<string, EvaluateFunction> = {};
_serviceSubscriptions = [];
defaultTool: Record<string, unknown>;
constructor(commandsManager: CommandsManager) {
constructor(
commandsManager: CommandsManager,
extensionManager: ExtensionManager,
servicesManager: ServicesManager
) {
super(EVENTS);
this._commandsManager = commandsManager;
}
public init(extensionManager: ExtensionManager): void {
this.extensionManager = extensionManager;
this._extensionManager = extensionManager;
this._servicesManager = servicesManager;
}
public reset(): void {
this.unsubscriptions.forEach(unsub => unsub());
this.state = {
primaryToolId: 'WindowLevel',
toggles: {},
groups: {},
buttons: {},
buttonSections: {},
};
this.unsubscriptions = [];
this.buttonSections = {};
this.buttons = {};
}
public onModeEnter(): void {
@@ -111,16 +81,52 @@ export default class ToolbarService extends PubSubService {
}
/**
* Sets the default tool that will be activated whenever the primary tool is
* deactivated without activating another/different tool.
* @param interaction the interaction command that will set the default tool active
* Registers an evaluate function with the specified name.
*
* @param name - The name of the evaluate function.
* @param handler - The evaluate function handler.
*/
public setDefaultTool(interaction) {
this.defaultTool = interaction;
public registerEvaluateFunction(name: string, handler: EvaluateFunction) {
this._evaluateFunction[name] = handler;
}
public getDefaultTool() {
return this.defaultTool;
/**
* Registers a service and its event to listen for updates and refreshes the toolbar state when the event is triggered.
* @param service - The service to register.
* @param event - The event to listen for.
*/
public registerEventForToolbarUpdate(service, events) {
const { viewportGridService } = this._servicesManager.services;
const callback = () => {
const viewportId = viewportGridService.getActiveViewportId();
this.refreshToolbarState({ viewportId });
};
const unsubscriptions = events.map(event => {
if (service.subscribe) {
return service.subscribe(event, callback);
} else if (service.addEventListener) {
return service.addEventListener(event, callback);
}
});
unsubscriptions.forEach(unsub => this._serviceSubscriptions.push(unsub));
}
/**
* Adds buttons to the toolbar.
* @param buttons - The buttons to be added.
*/
public addButtons(buttons: Button[]): void {
buttons.forEach(button => {
if (!this.state.buttons[button.id]) {
this.state.buttons[button.id] = button;
}
});
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
...this.state,
});
}
/**
@@ -130,160 +136,180 @@ export default class ToolbarService extends PubSubService {
* used for calling the specified interaction. That is, the command is
* called with {...commandOptions,...options}
*/
public recordInteraction(interaction, options?: Record<string, unknown>) {
if (!interaction) {
return;
public recordInteraction(
interaction,
options?: {
refreshProps: Record<string, unknown>;
[key: string]: unknown;
}
const commandsManager = this._commandsManager;
const { groupId, itemId, commands, type } = interaction;
let { interactionType } = interaction;
// if not interaction type, assume the type can be used
if (!interactionType) {
interactionType = type;
) {
// if interaction is a string, we can assume it is the itemId
// and get the props to get the other properties
if (typeof interaction === 'string') {
interaction = this.getButtonProps(interaction);
}
switch (interactionType) {
case 'action': {
commandsManager.run(commands, options);
break;
}
case 'tool': {
try {
const alternateInteraction =
this.state.primaryToolId === itemId &&
this.defaultTool?.itemId !== itemId &&
this.getDefaultTool();
if (alternateInteraction) {
// Allow toggling the mode off
return this.recordInteraction(alternateInteraction, options);
}
commandsManager.run(commands, options);
const itemId = interaction.itemId ?? interaction.id;
interaction.itemId = itemId;
// only set the primary tool if no error was thrown.
// if the itemId is not undefined use it; otherwise, set the first tool in
// the commands as the primary tool
this.state.primaryToolId = itemId || commands[0].commandOptions?.toolName;
} catch (error) {
console.warn(error);
}
const commands = Array.isArray(interaction.commands)
? interaction.commands
: [interaction.commands];
break;
}
case 'toggle': {
const { commands } = interaction;
let commandExecuted;
// only toggle if a command was executed
this.state.toggles[itemId] =
this.state.toggles[itemId] === undefined ? true : !this.state.toggles[itemId];
if (!commands) {
break;
}
commands.forEach(({ commandName, commandOptions, context }) => {
if (!commandOptions) {
commandOptions = {};
}
if (commandName) {
commandOptions.toggledState = this.state.toggles[itemId];
try {
commandsManager.runCommand(commandName, commandOptions, context);
commandExecuted = true;
} catch (error) {
console.warn(error);
}
}
});
if (!commandExecuted) {
// If no command was executed, we need to toggle the state back
this.state.toggles[itemId] = !this.state.toggles[itemId];
}
break;
}
default:
throw new Error(`Invalid interaction type: ${interactionType}`);
}
// Todo: comment out for now
// Run command if there's one associated
//
// NOTE: Should probably just do this for tools as well?
// But would be nice if we could enforce at least the command name?
// let unsubscribe;
// if (commandName) {
// unsubscribe = commandsManager.runCommand(commandName, commandOptions);
// }
// // Storing the unsubscribe for later resetting
// if (unsubscribe && typeof unsubscribe === 'function') {
// if (this.unsubscriptions.indexOf(unsubscribe) === -1) {
// this.unsubscriptions.push(unsubscribe);
// }
// }
// Track last touched id for each group
if (groupId) {
this.state.groups[groupId] = itemId;
}
this._broadcastEvent(this.EVENTS.TOOL_BAR_STATE_MODIFIED, { ...this.state });
}
getButtons() {
return this.buttons;
}
getActiveTools() {
const activeTools = [this.state.primaryToolId];
Object.keys(this.state.toggles).forEach(key => {
if (this.state.toggles[key]) {
activeTools.push(key);
}
});
return activeTools;
}
getActivePrimaryTool() {
return this.state.primaryToolId;
}
/** Sets the toggle state of a button to the isToggled state */
public setToggled(id: string, isToggled: boolean): void {
if (isToggled) {
this.state.toggles[id] = true;
} else {
delete this.state.toggles[id];
}
}
setButton(id, button) {
if (this.buttons[id]) {
this.buttons[id] = merge(this.buttons[id], button);
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
buttons: this.buttons,
button: this.buttons[id],
buttonSections: this.buttonSections,
if (!commands?.length) {
this.refreshToolbarState({
...options?.refreshProps,
itemId,
interaction,
});
}
const commandOptions = { ...options, ...interaction };
// Loop through commands and run them with the combined options
this._commandsManager.run(commands, commandOptions);
this.refreshToolbarState({
...options?.refreshProps,
itemId,
interaction,
});
}
/**
* Consolidates the state of the toolbar after an interaction, it accepts
* props that get passed to the buttons
*
* @param refreshProps - The props that buttons need to get evaluated, they can be
* { viewportId, toolGroup} for cornerstoneTools.
*
* Todo: right now refreshToolbarState should be used in the context where
* we have access to the toolGroup and viewportId, but we should be able to
* pass the props to the toolbar service and it should be able to decide
* which buttons to evaluate based on the props
*/
public refreshToolbarState(refreshProps) {
const buttons = this.state.buttons;
// Tracks evaluated buttons to avoid re-evaluating them (this will
// cause issue for toggles where if the button is in primary
// and secondary it will be evaluated twice)
const evaluationResults = new Map();
const evaluateButtonProps = (button, props, refreshProps) => {
if (evaluationResults.has(button.id)) {
const { disabled, className, isActive } = evaluationResults.get(button.id);
return { ...props, disabled, className, isActive };
} else {
const evaluated = props.evaluate?.({ ...refreshProps, button });
const updatedProps = {
...props,
disabled: evaluated?.disabled || false,
className: evaluated?.className || '',
isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop
};
evaluationResults.set(button.id, updatedProps);
return updatedProps;
}
};
const refreshedButtons = Object.values(buttons).reduce((acc, button: Button) => {
const isNested = (button.props as NestedButtonProps)?.groupId;
if (!isNested) {
this.handleEvaluate(button.props);
const buttonProps = button.props as ButtonProps;
const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps);
acc[button.id] = {
...button,
props: updatedProps,
};
} else {
let buttonProps = button.props as NestedButtonProps;
// if it is nested we should perform evaluate on each item in the group
this.handleEvaluateNested(buttonProps);
const { evaluate: groupEvaluate } = buttonProps;
const groupEvaluated = groupEvaluate?.({ ...refreshProps, button });
// handle group evaluate function which might switch the primary
// item in the group
buttonProps = {
...buttonProps,
primary: groupEvaluated?.primary || buttonProps.primary,
};
const { primary, items } = buttonProps;
// primary and items evaluate functions
let updatedPrimary;
if (primary) {
updatedPrimary = evaluateButtonProps(primary, primary, refreshProps);
}
const updatedItems = items.map(item => evaluateButtonProps(item, item, refreshProps));
buttonProps = {
...buttonProps,
primary: updatedPrimary,
items: updatedItems,
};
acc[button.id] = {
...button,
props: buttonProps,
};
}
return acc;
}, {});
this.setButtons(refreshedButtons);
return this.state;
}
/**
* Sets the buttons for the toolbar, don't use this method to record an
* interaction, since it doesn't update the state of the buttons, use
* this if you know the buttons you want to set and you want to set them
* all at once.
* @param buttons - The buttons to set.
*/
public setButtons(buttons) {
this.state.buttons = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
buttons: this.state.buttons,
buttonSections: this.state.buttonSections,
});
}
/**
* Retrieves a button by its ID.
* @param id - The ID of the button to retrieve.
* @returns The button with the specified ID.
*/
public getButton(id: string): Button {
return this.buttons[id];
return this.state.buttons[id];
}
/** Gets a nested button, found in the items/props for the children */
public getNestedButton(id: string): Button {
if (this.buttons[id]) {
return this.buttons[id];
}
for (const buttonId of Object.keys(this.buttons)) {
const { primary, items } = this.buttons[buttonId].props || {};
/**
* Retrieves the buttons from the toolbar service.
* @returns An array of buttons.
*/
public getButtons() {
return this.state.buttons;
}
/**
* Retrieves the button properties for the specified button ID.
* It prioritizes nested buttons over regular buttons if the ID is found
* in both.
*
* @param id - The ID of the button.
* @returns The button properties.
*/
public getButtonProps(id: string): ButtonProps {
for (const buttonId of Object.keys(this.state.buttons)) {
const { primary, items } = (this.state.buttons[buttonId].props as NestedButtonProps) || {};
if (primary?.id === id) {
return primary;
}
@@ -292,97 +318,57 @@ export default class ToolbarService extends PubSubService {
return found;
}
}
// This should be checked after we checked the nested buttons, since
// we are checking based on the ids, the nested objects are higher priority
// and more specific
if (this.state.buttons[id]) {
return this.state.buttons[id].props as ButtonProps;
}
}
setButtons(buttons) {
this.buttons = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
buttons: this.buttons,
buttonSections: this.buttonSections,
});
}
_getButtonUITypes() {
const registeredToolbarModules = this._extensionManager.modules['toolbarModule'];
_buttonTypes() {
const buttonTypes = {};
const registeredToolbarModules = this.extensionManager.modules['toolbarModule'];
if (Array.isArray(registeredToolbarModules) && registeredToolbarModules.length) {
registeredToolbarModules.forEach(toolbarModule =>
toolbarModule.module.forEach(def => {
buttonTypes[def.name] = def;
})
);
if (!Array.isArray(registeredToolbarModules)) {
return {};
}
return buttonTypes;
}
createButtonSection(key, buttons) {
// Maybe do this mapping at time of return, instead of time of create
// Props check important for validation here...
this.buttonSections[key] = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {});
}
/**
*
* Finds a button section by it's name/tool group id, then maps the list of string name
* identifiers to schema/values that can be used to render the buttons.
*
* @param toolGroupId - the tool group id
* @param props - optional properties to apply to every button of the section
* @param defaultToolGroupId - the fallback section to return if the given toolGroupId section is not available
*/
getButtonSection(
toolGroupId: string,
props?: Record<string, unknown>,
defaultToolGroupId = 'primary'
) {
const buttonSectionIds =
this.buttonSections[toolGroupId] || this.buttonSections[defaultToolGroupId];
const buttonsInSection = [];
if (buttonSectionIds && buttonSectionIds.length !== 0) {
buttonSectionIds.forEach(btnId => {
const btn = this.buttons[btnId];
const metadata = {};
const mappedBtn = this._mapButtonToDisplay(btn, toolGroupId, metadata, props);
buttonsInSection.push(mappedBtn);
return registeredToolbarModules.reduce((buttonTypes, toolbarModule) => {
toolbarModule.module.forEach(def => {
buttonTypes[def.name] = def;
});
}
return buttonsInSection;
return buttonTypes;
}, {});
}
/**
*
* @param {object[]} buttons
* @param {string} buttons[].id
* Creates a button section with the specified key and buttons.
* @param {string} key - The key of the button section.
* @param {Array} buttons - The buttons to be added to the section.
*/
addButtons(buttons) {
buttons.forEach(button => {
if (!this.buttons[button.id]) {
this.buttons[button.id] = button;
}
});
this._setTogglesForButtonItems(buttons);
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {});
createButtonSection(key, buttons) {
this.state.buttonSections[key] = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
}
_setTogglesForButtonItems(buttons) {
if (!buttons) {
return;
}
/**
* Retrieves the button section with the specified sectionId.
*
* @param sectionId - The ID of the button section to retrieve.
* @param props - Optional additional properties for mapping the button to display.
* @returns An array of buttons in the specified section, mapped to their display representation.
*/
getButtonSection(sectionId: string, props?: Record<string, unknown>) {
const buttonSectionIds = this.state.buttonSections[sectionId];
buttons.forEach(buttonItem => {
if (buttonItem.type === 'toggle') {
this.setToggled(buttonItem.id, buttonItem.isActive);
}
this._setTogglesForButtonItems(buttonItem.props?.items);
});
return (
buttonSectionIds?.map(btnId => {
const btn = this.state.buttons[btnId];
return this._mapButtonToDisplay(btn, props);
}) || []
);
}
/**
@@ -392,18 +378,22 @@ export default class ToolbarService extends PubSubService {
* @param {*} metadata
* @param {*} props - Props set by the Viewer layer
*/
_mapButtonToDisplay(btn, btnSection, metadata, props) {
_mapButtonToDisplay(btn, props) {
if (!btn) {
return;
}
const { id, type, component } = btn;
const buttonType = this._buttonTypes()[type];
const { id, uiType, component } = btn;
const { groupId } = btn.props;
const buttonType = this._getButtonUITypes()[uiType];
if (!buttonType) {
return;
}
!groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props);
return {
id,
Component: component || buttonType.defaultComponent,
@@ -411,7 +401,54 @@ export default class ToolbarService extends PubSubService {
};
}
handleEvaluateNested = props => {
const { primary, items } = props;
// handle group evaluate function
this.handleEvaluate(props);
// primary and items evaluate functions
if (primary) {
this.handleEvaluate(primary);
}
items.forEach(item => this.handleEvaluate(item));
};
handleEvaluate = props => {
const { evaluate } = props;
if (typeof evaluate === 'function') {
return;
}
if (typeof evaluate === 'string') {
const evaluateFunction = this._evaluateFunction[evaluate];
if (evaluateFunction) {
props.evaluate = evaluateFunction;
return;
}
throw new Error(
`Evaluate function not found for name: ${evaluate}, you can register an evaluate function with the getToolbarModule in your extensions`
);
}
if (typeof evaluate === 'object') {
const { name, options } = evaluate;
const evaluateFunction = this._evaluateFunction[name];
if (evaluateFunction) {
props.evaluate = args => evaluateFunction({ ...args, ...options });
return;
}
throw new Error(
`Evaluate function not found for name: ${name}, you can register an evaluate function with the getToolbarModule in your extensions`
);
}
};
getButtonComponentForUIType(uiType: string) {
return uiType ? this._buttonTypes()[uiType]?.defaultComponent ?? null : null;
return uiType ? this._getButtonUITypes()[uiType]?.defaultComponent ?? null : null;
}
}
@@ -0,0 +1,43 @@
import type { RunCommand } from '../../types/Command';
export type EvaluatePublic = string | EvaluateFunction;
export type EvaluateFunction = (props: Record<string, unknown>) => {
disabled: boolean;
className: string;
};
export type ButtonProps = {
id: string;
icon: string;
label: string;
tooltip?: string;
commands?: RunCommand;
disabled?: boolean;
className?: string;
evaluate?: EvaluatePublic;
listeners?: Record<string, RunCommand>;
};
export type NestedButtonProps = {
groupId: string;
// group evaluate which is different
// from the evaluate function for the primary and items
evaluate?: EvaluatePublic;
items: ButtonProps[];
primary: ButtonProps & {
// Todo: this is really ugly but really we don't have any other option
// the ui design requires this since the button should be rounded if
// active otherwise it should not be rounded
isActive?: boolean;
};
secondary: ButtonProps;
};
export type Button = {
id: string;
props: ButtonProps | NestedButtonProps;
// button ui type (e.g. 'ohif.splitButton', 'ohif.radioGroup')
// extensions can provide custom components for these types
uiType: string;
};
@@ -7,6 +7,7 @@ class ViewportGridService extends PubSubService {
LAYOUT_CHANGED: 'event::layoutChanged',
GRID_STATE_CHANGED: 'event::gridStateChanged',
GRID_SIZE_CHANGED: 'event::gridSizeChanged',
VIEWPORTS_READY: 'event::viewportsReady',
};
public static REGISTRATION = {
@@ -35,6 +36,7 @@ class ViewportGridService extends PubSubService {
onModeExit: onModeExitImplementation,
set: setImplementation,
getNumViewportPanes: getNumViewportPanesImplementation,
setViewportIsReady: setViewportIsReadyImplementation,
}): void {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
@@ -61,6 +63,14 @@ class ViewportGridService extends PubSubService {
if (getNumViewportPanesImplementation) {
this.serviceImplementation._getNumViewportPanes = getNumViewportPanesImplementation;
}
if (setViewportIsReadyImplementation) {
this.serviceImplementation._setViewportIsReady = setViewportIsReadyImplementation;
}
}
public publishViewportsReady() {
this._broadcastEvent(this.EVENTS.VIEWPORTS_READY, {});
}
public setActiveViewportId(id: string) {
@@ -74,6 +84,10 @@ class ViewportGridService extends PubSubService {
return this.serviceImplementation._getState();
}
public setViewportIsReady(viewportId, callback) {
this.serviceImplementation._setViewportIsReady(viewportId, callback);
}
public getActiveViewportId() {
const state = this.getState();
return state.activeViewportId;
@@ -110,6 +124,17 @@ class ViewportGridService extends PubSubService {
});
}
/**
* Retrieves the display set instance UIDs for a given viewport.
* @param viewportId The ID of the viewport.
* @returns An array of display set instance UIDs.
*/
public getDisplaySetsUIDsForViewport(viewportId: string) {
const state = this.getState();
const viewport = state.viewports.get(viewportId);
return viewport?.displaySetInstanceUIDs;
}
/**
*
* @param numCols, numRows - the number of columns and rows to apply
+2 -2
View File
@@ -13,11 +13,11 @@ import HangingProtocolService from './HangingProtocolService';
import pubSubServiceInterface, { PubSubService } from './_shared/pubSubServiceInterface';
import UserAuthenticationService from './UserAuthenticationService';
import CustomizationService from './CustomizationService';
import Services from '../types/Services';
import StateSyncService from './StateSyncService';
import PanelService from './PanelService';
import type Services from '../types/Services';
export {
Services,
MeasurementService,
+3 -1
View File
@@ -1,9 +1,11 @@
export interface Command {
export type SimpleCommand = string;
export interface ComplexCommand {
commandName: string;
commandOptions?: Record<string, unknown>;
context?: string;
}
export type Command = SimpleCommand | ComplexCommand;
export type RunCommand = Command | Command[];
/** A set of commands, typically contained in a tool item or other configuration */