feat(toolbox): Refactor Toolbar and Toolbox to enable sections (#4825)

This commit is contained in:
Alireza authored and GitHub committed 2025-03-06 22:19:47 -05:00
1 parent 86c12246ac
commit cc5cdfb081
79 files changed
+3527 -2992

No files matched your search

@@ -1,108 +1,95 @@
describe('OHIF Download Snapshot File', () => {
beforeEach(() => {
cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
cy.expectMinimumThumbnails(3);
cy.openDownloadImageModal();
});
// describe('OHIF Download Snapshot File', () => {
// beforeEach(() => {
// cy.checkStudyRouteInViewer('1.2.840.113619.2.5.1762583153.215519.978957063.78');
// cy.expectMinimumThumbnails(3);
// cy.openDownloadImageModal();
// });
it('checks displayed information for Desktop experience', function () {
// Set Desktop resolution
// cy.viewport(1750, 720);
// Visual comparison
// cy.screenshot('Download Image Modal - Desktop experience');
//Check if all elements are displayed
// it('checks displayed information for Desktop experience', function () {
// // Set Desktop resolution
// // cy.viewport(1750, 720);
// // Visual comparison
// // cy.screenshot('Download Image Modal - Desktop experience');
// //Check if all elements are displayed
// TODO: need to add this attribute to the modal
cy.get('[data-cy=modal-header]')
.as('downloadImageModal')
.should('contain.text', 'Download High Quality Image');
// // Check image preview
// cy.get('[data-cy="image-preview"]').should('contain.text', 'Image preview');
// Check input fields
// TODO: select2
// cy.get('[data-cy="file-type"]')
// .select('png')
// .should('have.value', 'png')
// .select('jpg')
// .should('have.value', 'jpg');
// //TODO: This is a canvas now, not an img with src
// // cy.get('[data-cy="viewport-preview-img"]')
// // .should('have.attr', 'src')
// // .and('include', 'data:image');
// Check image preview
cy.get('[data-cy="image-preview"]').should('contain.text', 'Image preview');
// // Check buttons
// cy.get('[data-cy="cancel-btn"]').scrollIntoView().should('be.visible');
// cy.get('[data-cy="download-btn"]').scrollIntoView().should('be.visible');
//TODO: This is a canvas now, not an img with src
// cy.get('[data-cy="viewport-preview-img"]')
// .should('have.attr', 'src')
// .and('include', 'data:image');
// cy.get('[data-cy="cancel-btn"]').click();
// });
// Check buttons
cy.get('[data-cy="cancel-btn"]').scrollIntoView().should('be.visible');
cy.get('[data-cy="download-btn"]').scrollIntoView().should('be.visible');
// /*it('cancel changes on download modal', function() {
// //Change Image Width, Filename and File Type
// cy.get('[data-cy="image-width"]')
// .clear()
// .type('300');
// cy.get('[data-cy="image-height"]') //Image Height should be the same as width
// .should('have.value', '300');
// cy.get('[data-cy="file-name"]')
// .clear()
// .type('new-filename');
// cy.get('[data-cy="file-type"]').select('png');
// //Click on Cancel button
// cy.get('[data-cy="cancel-btn"]')
// .scrollIntoView()
// .click();
// //Check modal is closed
// cy.get('[data-cy="modal"]').should('not.exist');
// //Open Modal
// cy.openDownloadImageModal();
// //Verify default values was restored
// cy.get('[data-cy="image-width"]').should('have.value', '512');
// cy.get('[data-cy="file-name"]').should('have.value', 'image');
// cy.get('[data-cy=file-type]').should('have.value', 'jpg');
// });*/
cy.get('[data-cy="cancel-btn"]').click();
});
// // TO-DO once issue is fixed: https://github.com/OHIF/Viewers/issues/1217
// // it('checks error messages for empty fields', function() {
// // //Clear fields Image Width and Filename
// // cy.get('[data-cy="image-width"]').clear();
// // cy.get('[data-cy="file-name"]').clear();
/*it('cancel changes on download modal', function() {
//Change Image Width, Filename and File Type
cy.get('[data-cy="image-width"]')
.clear()
.type('300');
cy.get('[data-cy="image-height"]') //Image Height should be the same as width
.should('have.value', '300');
cy.get('[data-cy="file-name"]')
.clear()
.type('new-filename');
cy.get('[data-cy="file-type"]').select('png');
//Click on Cancel button
cy.get('[data-cy="cancel-btn"]')
.scrollIntoView()
.click();
//Check modal is closed
cy.get('[data-cy="modal"]').should('not.exist');
//Open Modal
cy.openDownloadImageModal();
//Verify default values was restored
cy.get('[data-cy="image-width"]').should('have.value', '512');
cy.get('[data-cy="file-name"]').should('have.value', 'image');
cy.get('[data-cy=file-type]').should('have.value', 'jpg');
});*/
// // //Click on Download button
// // cy.get('[data-cy="download-btn"]')
// // .scrollIntoView()
// // .click();
// // //Check error message
// // });
// TO-DO once issue is fixed: https://github.com/OHIF/Viewers/issues/1217
// it('checks error messages for empty fields', function() {
// //Clear fields Image Width and Filename
// cy.get('[data-cy="image-width"]').clear();
// cy.get('[data-cy="file-name"]').clear();
// /*it('checks if "Show Annotations" checkbox will display annotations', function() {
// // Close modal that is initially opened
// cy.get('[data-cy="close-button"]').click();
// //Click on Download button
// cy.get('[data-cy="download-btn"]')
// .scrollIntoView()
// .click();
// //Check error message
// });
// // Add measurements in the viewport
// cy.addLengthMeasurement();
// cy.addAngleMeasurement();
/*it('checks if "Show Annotations" checkbox will display annotations', function() {
// Close modal that is initially opened
cy.get('[data-cy="close-button"]').click();
// Add measurements in the viewport
cy.addLengthMeasurement();
cy.addAngleMeasurement();
// Open Modal
cy.openDownloadImageModal();
// Select "Show Annotations" option
cy.get('[data-cy="show-annotations"]').check();
// Check image preview
cy.get('[data-cy="image-preview"]').scrollIntoView();
//Compare classes that exists on Image Preview with Annotations and Without Annotation
cy.get('[data-cy="modal-content"]')
.find('canvas')
.should('have.class', 'magnifyTool'); //Class "MagnifyTool" exists with annotations displayed on Image preview
// Uncheck "Show Annotations" option
cy.get('[data-cy="show-annotations"]')
.uncheck()
.wait(300);
// Check that class "MagnifyTool" should not exist
cy.get('[data-cy="modal-content"]')
.find('canvas')
.should('not.have.class', 'magnifyTool');
});*/
});
// // Open Modal
// cy.openDownloadImageModal();
// // Select "Show Annotations" option
// cy.get('[data-cy="show-annotations"]').check();
// // Check image preview
// cy.get('[data-cy="image-preview"]').scrollIntoView();
// //Compare classes that exists on Image Preview with Annotations and Without Annotation
// cy.get('[data-cy="modal-content"]')
// .find('canvas')
// .should('have.class', 'magnifyTool'); //Class "MagnifyTool" exists with annotations displayed on Image preview
// // Uncheck "Show Annotations" option
// cy.get('[data-cy="show-annotations"]')
// .uncheck()
// .wait(300);
// // Check that class "MagnifyTool" should not exist
// cy.get('[data-cy="modal-content"]')
// .find('canvas')
// .should('not.have.class', 'magnifyTool');
// });*/
// });
+2 -2
View File
@@ -4,8 +4,8 @@ export function initCornerstoneToolsAliases() {
// cy.get('[data-cy="StackScroll"]').as('stackScrollBtn');
cy.get('[data-cy="Zoom"]').as('zoomBtn');
cy.get('[data-cy="WindowLevel-split-button-primary"]').as('wwwcBtnPrimary');
cy.get('[data-cy="WindowLevel-split-button-secondary"]').as('wwwcBtnSecondary');
cy.get('[data-cy="WindowLevelGroup-split-button-primary"]').as('wwwcBtnPrimary');
cy.get('[data-cy="WindowLevelGroup-split-button-secondary"]').as('wwwcBtnSecondary');
cy.get('[data-cy="Pan"]').as('panBtn');
cy.get('[data-cy="MeasurementTools-split-button-primary"]').as('measurementToolsBtnPrimary');
cy.get('[data-cy="MeasurementTools-split-button-secondary"]').as('measurementToolsBtnSecondary');
+1 -1
View File
@@ -54,7 +54,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^3.0.1",
"@cornerstonejs/dicom-image-loader": "^3.0.4",
"@emotion/serialize": "^1.1.3",
"@ohif/core": "3.10.0-beta.116",
"@ohif/extension-cornerstone": "3.10.0-beta.116",
+1 -7
View File
@@ -6,13 +6,7 @@ window.config = {
// whiteLabeling: {},
extensions: [],
modes: [],
customizationService: {
'viewportActionMenu.windowLevelActionMenu': {
$merge: {
location: 0,
},
},
},
customizationService: {},
showStudyList: true,
// some windows systems have issues with more than 3 web workers
maxNumberOfWebWorkers: 3,
-2
View File
@@ -26,7 +26,6 @@ import {
ViewportGridProvider,
DialogProvider,
TooltipProvider,
ToolboxProvider,
Modal as ModalNext,
ManagedDialog,
ModalProvider,
@@ -117,7 +116,6 @@ function App({
[ThemeWrapperNext],
[ThemeWrapper],
[SystemContextProvider, { commandsManager, extensionManager, hotkeysManager, servicesManager }],
[ToolboxProvider],
[ViewportGridProvider, { service: viewportGridService }],
[ViewportDialogProvider, { service: uiViewportDialogService }],
[CineProvider, { service: cineService }],
+35 -3
View File
@@ -1,4 +1,3 @@
import { hotkeys } from '@ohif/core';
import { initToolGroups, toolbarButtons } from '@ohif/mode-longitudinal';
import { id } from './id';
@@ -50,14 +49,47 @@ function modeFactory({ modeConfiguration }) {
toolbarService.addButtons(toolbarButtons);
toolbarService.createButtonSection('primary', [
'MeasurementTools',
'measurementSection',
'Zoom',
'WindowLevel',
'Pan',
'Capture',
'Layout',
'Crosshairs',
'MoreTools',
'moreToolsSection',
]);
toolbarService.createButtonSection('measurementSection', [
'Length',
'Bidirectional',
'ArrowAnnotate',
'EllipticalROI',
'RectangleROI',
'CircleROI',
'PlanarFreehandROI',
'SplineROI',
'LivewireContour',
]);
toolbarService.createButtonSection('moreToolsSection', [
'Reset',
'rotate-right',
'flipHorizontal',
'ImageSliceSync',
'ReferenceLines',
'ImageOverlayViewer',
'StackScroll',
'invert',
'Probe',
'Cine',
'Angle',
'CobbAngle',
'Magnify',
'CalibrationLine',
'TagBrowser',
'AdvancedMagnify',
'UltrasoundDirectionalTool',
'WindowLevelRegion',
]);
},
onModeExit: ({ servicesManager }: withAppTypes) => {
+1 -1
View File
@@ -37,7 +37,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^3.0.1",
"@cornerstonejs/dicom-image-loader": "^3.0.4",
"@ohif/ui": "3.10.0-beta.116",
"cornerstone-math": "0.1.9",
"dicom-parser": "^1.8.21"
+49 -8
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useSystem } from '../contextProviders/SystemProvider';
export function useToolbar({ servicesManager, buttonSection = 'primary' }: withAppTypes) {
export function useToolbar({ buttonSection = 'primary' }: withAppTypes) {
const { commandsManager, servicesManager } = useSystem();
const { toolbarService, viewportGridService } = servicesManager.services;
const { EVENTS } = toolbarService;
@@ -11,15 +13,54 @@ export function useToolbar({ servicesManager, buttonSection = 'primary' }: withA
// Callback function for handling toolbar interactions
const onInteraction = useCallback(
args => {
args.event?.stopPropagation?.();
const viewportId = viewportGridService.getActiveViewportId();
const refreshProps = {
viewportId,
};
toolbarService.recordInteraction(args, {
refreshProps,
});
const refreshProps = { viewportId };
const buttonProps = toolbarService.getButtonProps(args.itemId);
if (buttonProps.commands || buttonProps.options) {
const allCommands = [];
const options = buttonProps.options || [];
const itemCommands = buttonProps.commands || [];
// Process item commands
if (itemCommands) {
Array.isArray(itemCommands)
? allCommands.push(...itemCommands)
: allCommands.push(itemCommands);
}
// Process commands from options
if (options.length > 0) {
options.forEach(option => {
if (!option.commands) {
return;
}
const valueToUse = option.value;
const commands = Array.isArray(option.commands) ? option.commands : [option.commands];
commands.forEach(command => {
const commandOptions = {
...option,
value: valueToUse,
options: buttonProps.options,
servicesManager: servicesManager,
commandsManager: commandsManager,
};
const processedCommand = () => commandsManager.run(command, commandOptions);
allCommands.push(processedCommand);
});
});
}
buttonProps.commands = allCommands;
}
toolbarService.recordInteraction({ ...args, ...buttonProps }, { refreshProps });
},
[toolbarService, viewportGridService]
[toolbarService, viewportGridService, toolbarButtons]
);
// Effect to handle toolbar modification events
@@ -134,7 +134,7 @@ export default class ToolbarService extends PubSubService {
buttons.forEach(button => {
if (replace || !this.state.buttons[button.id]) {
if (!button.props) {
button.props = {};
button.props = {} as ButtonProps;
}
this.state.buttons[button.id] = button;
@@ -173,12 +173,16 @@ export default class ToolbarService extends PubSubService {
? interaction.commands
: [interaction.commands];
commands = commands.filter(Boolean);
if (!commands?.length) {
this.refreshToolbarState({
...options?.refreshProps,
itemId,
interaction,
});
return;
}
const commandOptions = { ...options, ...interaction };
@@ -228,10 +232,6 @@ export default class ToolbarService extends PubSubService {
*/
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) => {
@@ -252,59 +252,50 @@ export default class ToolbarService extends PubSubService {
}
};
const refreshedButtons = Object.values(buttons).reduce((acc, button: Button) => {
const isNested = (button.props as NestedButtonProps)?.groupId;
Object.values(buttons).forEach(button => {
const hasSection = (button.props as NestedButtonProps)?.buttonSection;
if (!isNested) {
if (!hasSection) {
this.handleEvaluate(button.props);
const buttonProps = button.props as ButtonProps;
const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps);
acc[button.id] = {
buttons[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 =
typeof groupEvaluate === 'function'
? groupEvaluate({ ...refreshProps, button })
: undefined;
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,
disabled: groupEvaluated?.disabled ?? buttonProps.disabled,
disabledText: groupEvaluated?.disabledText ?? buttonProps.disabledText,
};
const { primary, items } = buttonProps;
const toolButtonIds = this.state.buttonSections[buttonProps.buttonSection];
// primary and items evaluate functions
let updatedPrimary;
if (primary) {
updatedPrimary = evaluateButtonProps(primary, primary, refreshProps);
if (!toolButtonIds) {
return;
}
const updatedItems = items.map(item => evaluateButtonProps(item, item, refreshProps));
buttonProps = {
...buttonProps,
primary: updatedPrimary,
items: updatedItems,
};
acc[button.id] = {
...button,
props: buttonProps,
};
toolButtonIds.forEach(buttonId => {
const button = buttons[buttonId];
const updatedProps = evaluateButtonProps(button, button.props, refreshProps);
buttons[buttonId] = {
...button,
props: updatedProps,
};
});
}
});
return acc;
}, {});
this.setButtons(refreshedButtons);
this.setButtons(buttons);
return this.state;
}
@@ -349,17 +340,6 @@ export default class ToolbarService extends PubSubService {
* @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;
}
const found = items?.find(childButton => childButton.id === id);
if (found) {
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
@@ -421,6 +401,12 @@ export default class ToolbarService extends PubSubService {
);
}
getButtonPropsInButtonSection(sectionId: string) {
const buttonSectionIds = this.state.buttonSections[sectionId];
return buttonSectionIds?.map(btnId => this.getButtonProps(btnId)) || [];
}
/**
* Retrieves the tool name for a given button.
* @param button - The button object.
@@ -448,41 +434,119 @@ export default class ToolbarService extends PubSubService {
* @param {*} metadata
* @param {*} props - Props set by the Viewer layer
*/
_mapButtonToDisplay(btn, props) {
_mapButtonToDisplay(btn: Button, props: Record<string, unknown>) {
if (!btn) {
return;
}
const { id, uiType, component } = btn;
const { groupId } = btn.props;
const { id, uiType } = btn;
const { groupId } = btn.props as NestedButtonProps;
const buttonTypes = this._getButtonUITypes();
const buttonType = buttonTypes[uiType];
if (!buttonType && !component) {
if (!btn.component) {
btn.component = buttonType.defaultComponent;
}
if (!buttonType) {
return;
}
!groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props);
const { id: buttonId, props: componentProps } = btn;
const createEnhancedOptions = (options, itemId) => {
const optionsToUse = Array.isArray(options) ? options : [options];
const toolProps = this.getButtonProps(itemId);
return optionsToUse.map(option => {
if (typeof option.optionComponent === 'function') {
return option;
}
return {
...option,
onChange: value => {
// Update the option's value for UI
option.value = value;
const cmds = Array.isArray(option.commands) ? option.commands : [option.commands];
// Find the parent button and update its options
if (toolProps && toolProps.options) {
// Find the option in the button's options array and update its value
const optionIndex = toolProps.options.findIndex(opt => opt.id === option.id);
if (optionIndex !== -1) {
toolProps.options[optionIndex].value = value;
}
}
cmds.forEach(command => {
const commandOptions = {
...option,
value,
options: toolProps.options,
servicesManager: this._servicesManager,
commandsManager: this._commandsManager,
};
this._commandsManager.run(command, commandOptions);
});
// Notify that toolbar state has been modified
this._broadcastEvent(EVENTS.TOOL_BAR_STATE_MODIFIED, {
buttons: this.state.buttons,
buttonSections: this.state.buttonSections,
});
},
};
});
};
if ((componentProps as NestedButtonProps)?.items?.length) {
const { items = [] } = componentProps as NestedButtonProps;
items.forEach(item => {
if (!item.options) {
return;
}
item.options = createEnhancedOptions(item.options, item.id);
});
} else if ((componentProps as ButtonProps).options?.length) {
(componentProps as ButtonProps).options = createEnhancedOptions(
(componentProps as ButtonProps).options,
buttonId
);
} else if ((componentProps as ButtonProps).optionComponent) {
(componentProps as ButtonProps).optionComponent = options.optionComponent;
}
return {
id,
Component: component || buttonType.defaultComponent,
componentProps: Object.assign({}, btn.props, props),
Component: btn.component,
componentProps: Object.assign({ id }, btn.props, props),
};
}
handleEvaluateNested = props => {
const { primary, items } = props;
// handle group evaluate function
this.handleEvaluate(props);
const { buttonSection } = props;
// primary and items evaluate functions
if (primary) {
this.handleEvaluate(primary);
if (!buttonSection) {
return;
}
items.forEach(item => this.handleEvaluate(item));
const toolbarButtons = this.getButtonSection(buttonSection);
if (!toolbarButtons?.length) {
return;
}
toolbarButtons.forEach(button => {
this.handleEvaluate(button.componentProps);
});
};
handleEvaluate = props => {
@@ -492,9 +556,7 @@ export default class ToolbarService extends PubSubService {
// get the custom option component from the extension manager and set it as the optionComponent
const buttonTypes = this._getButtonUITypes();
const optionComponent = buttonTypes[options]?.defaultComponent;
props.options = {
optionComponent,
};
props.options = optionComponent;
}
if (typeof evaluate === 'function') {
@@ -1,4 +1,5 @@
import type { RunCommand } from '../../types/Command';
import React from 'react';
export type EvaluatePublic =
| string
@@ -17,6 +18,20 @@ export type EvaluateObject = {
[key: string]: unknown;
};
export type ButtonOptions = {
id: string;
type: 'range' | 'radio' | 'double-range' | 'custom';
name?: string;
min?: number;
max?: number;
step?: number;
value?: number | number[] | string;
commands?: (value: unknown) => void;
condition?: (props: Record<string, unknown>) => boolean;
children?: React.ReactNode | (() => React.ReactNode);
options?: Array<{ value: string; label: string }>;
};
export type ButtonProps = {
id: string;
icon: string;
@@ -27,27 +42,15 @@ export type ButtonProps = {
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;
options?: ButtonOptions[];
buttonSection?: string;
};
export type Button = {
id: string;
props: ButtonProps | NestedButtonProps;
props: ButtonProps;
// button ui type (e.g. 'ohif.splitButton', 'ohif.radioGroup')
// extensions can provide custom components for these types
uiType: string;
component?: React.ComponentType<any>;
};
@@ -27,7 +27,7 @@ export const EVENTS = {
sections: [
{
key: 'primary',
buttons: [ 'MeasurementTools', 'Zoom', ... ],
buttons: [ 'measurementSection', 'Zoom', ... ],
},
],
},
@@ -72,11 +72,114 @@ The `ohif.buttonGroup` and `ohif.radioGroup` types used in the Toolbox have been
The `getToolbarModule` function previously returned `disabled`, `disabledText`, and `className` as part of its evaluation process for the button state. These properties will still be returned, but common class names are now handled internally by the new UI button components, including `ToolButton`, `ToolButtonList`, `Toolbox`, and `ToolBoxGroup`. You can override the `className` if you need to.
## Tool Definitions: Moving to Section-Based Definitions
This migration represents a significant step toward a more extension-based toolbar system. We've moved away from the nested primary/items structure in favor of a flatter, more composable section-based approach.
### Deprecated: Nested Toolbar Structures
```diff
- {
- id: 'MeasurementTools',
- uiType: 'ohif.toolButtonList',
- props: {
- groupId: 'MeasurementTools',
- evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList',
- primary: createButton({
- id: 'Length',
- icon: 'tool-length',
- label: 'Length',
- tooltip: 'Length Tool',
- commands: setToolActiveToolbar,
- evaluate: 'evaluate.cornerstoneTool',
- }),
- secondary: {
- icon: 'chevron-down',
- tooltip: 'More Measure Tools',
- },
- items: [
- createButton({ ... }),
- createButton({ ... }),
- // More nested buttons
- ],
- },
- }
```
### New Approach: Section-Based Definitions
```diff
+ // 1. Define the toolbar section container
+ {
+ id: 'MeasurementTools',
+ uiType: 'ohif.toolButtonList',
+ props: {
+ buttonSection: 'measurementSection',
+ groupId: 'MeasurementTools',
+ },
+ },
+ // 2. Register individual buttons separately
+ {
+ id: 'Length',
+ uiType: 'ohif.toolButton',
+ props: {
+ icon: 'tool-length',
+ label: 'Length',
+ tooltip: 'Length Tool',
+ commands: setToolActiveToolbar,
+ evaluate: 'evaluate.cornerstoneTool',
+ },
+ },
+ {
+ id: 'Bidirectional',
+ uiType: 'ohif.toolButton',
+ props: {
+ icon: 'tool-bidirectional',
+ label: 'Bidirectional',
+ tooltip: 'Bidirectional Tool',
+ commands: setToolActiveToolbar,
+ evaluate: 'evaluate.cornerstoneTool',
+ },
+ },
```
and then in your mode you can compose the section and associate buttons
```diff
+ // 3. In your mode, create the section and associate buttons
+ toolbarService.createButtonSection('primary', [
+ 'MeasurementTools',
+ 'Pan',
+ 'Zoom',
+ ]);
+
+
+ toolbarService.createButtonSection('measurementSection', [
+ 'Length',
+ 'Bidirectional',
+ 'ArrowAnnotate',
+ 'EllipticalROI',
+ ]);
```
:::note
The `measurementSection` is defined in the tool button configuration of the `MeasurementTools` button.
:::
### Group Evaluators Deprecated
Group evaluator functions like `evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList` are now deprecated. Instead, the `uiType` component itself is responsible for grouping and displaying the buttons from a section as needed. This allows for more flexible UI implementations that aren't tied to specific evaluation logic.
## ToolBox
Previously, the segmentation toolbox was not using an `evaluator` property. This is now taken into account
### evaluators in Toolbox
```js
// old
{
@@ -84,7 +187,6 @@ Previously, the segmentation toolbox was not using an `evaluator` property. This
uiType: 'ohif.buttonGroup',
props: {
groupId: 'BrushTools',
items: []
}
}
@@ -95,7 +197,237 @@ Previously, the segmentation toolbox was not using an `evaluator` property. This
props: {
groupId: 'BrushTools',
evaluate: 'evaluate.cornerstone.hasSegmentation',
items: []
}
}
```
### Replace Toolbox imports from ui-next with extension-default
```diff
- import { Toolbox } from '@ohif/ui-next';
+ import { Toolbox } from '@ohif/extension-default';
```
Ensure you're importing the Toolbox component from the correct location:
```javascript
// New import pattern
import { Toolbox } from '@ohif/extension-default';
// Usage remains similar
<Toolbox
servicesManager={servicesManager}
buttonSectionId="segmentation"
title="Segmentation Tools"
/>
```
### Stacked Sections in Toolbox
The new Toolbox component supports stacked sections, which allows for more complex UI organization. Instead of flat button groups, you can now create deep hierarchies of tool sections and subsections.
Previously you were able to have something like this
```js
// old
// buttons for BrushTools were a giant group of buttons
const buttons = {
id: 'BrushTools',
uiType: 'ohif.toolBoxButtonGroup',
props: {
groupId: 'BrushTools',
evaluate: 'evaluate.cornerstone.hasSegmentation',
items: [
{
id: 'Brush',
icon: 'icon-tool-brush',
label: 'Brush',
evaluate: {
// ...
},
options: [
// ...
],
},
{
id: 'Eraser',
icon: 'icon-tool-eraser',
label: 'Eraser',
evaluate: {
// ...
},
options: [
// ...
],
},
{
id: 'Threshold',
icon: 'icon-tool-threshold',
label: 'Threshold Tool',
evaluate: {
// ...
},
options: [
// ...
],
},
],
},
},
{
id: 'Shapes',
uiType: 'ohif.toolBoxButton',
props: {
id: 'Shapes',
icon: 'icon-tool-shape',
label: 'Shapes',
evaluate: {
// ...
},
options: [
// ...
],
},
},
toolbarService.addButtons(buttons);
toolbarService.createButtonSection('segmentationToolbox', ['BrushTools', 'Shapes']);
```
But now you should have at least one section defined in your toolbar buttons
```js
// separate flat definitions for each button and each section
const buttons = [
{
id: 'Brush',
uiType: 'ohif.toolButton',
props: {
icon: 'icon-tool-brush',
label: 'Brush',
evaluate: {
// ...
},
options: [
// ...
],
},
},
{
id: 'Eraser',
uiType: 'ohif.toolButton',
props: {
icon: 'icon-tool-eraser',
label: 'Eraser',
evaluate: {
// ...
},
options: [
// ...
],
},
},
{
id: 'Threshold',
uiType: 'ohif.toolButton',
props: {
icon: 'icon-tool-threshold',
label: 'Threshold Tool',
evaluate: {
// ...
},
options: [
// ...
],
},
},
{
id: 'Shapes',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'icon-tool-shape',
label: 'Shapes',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'],
disabledText: 'Create new segmentation to enable shapes tool.',
},
options: [
// ...
],
},
},
// Sections
{
id: 'SegmentationTools',
uiType: 'ohif.toolBoxButton',
props: {
groupId: 'SegmentationTools',
buttonSection: 'segmentationToolboxToolsSection',
},
},
{
id: 'BrushTools',
uiType: 'ohif.toolBoxButtonGroup',
props: {
groupId: 'BrushTools',
buttonSection: 'brushToolsSection',
},
},
]
toolbarService.addButtons(buttons);
```
and then
```js
// Step 2: Create the section hierarchy
// Top level toolbox section
toolbarService.createButtonSection('segmentationToolbox', ['SegmentationTools']);
// Next level - subsections within the toolbox
toolbarService.createButtonSection('segmentationToolboxToolsSection', ['BrushTools', 'Shapes']);
// Lowest level - buttons within a subsection
toolbarService.createButtonSection('brushToolsSection', ['Brush', 'Eraser', 'Threshold']);
```
### Remove ToolboxProvider from composition root
If you have the ToolboxProvider in your application composition, remove it:
```diff
// In App.tsx or similar
const appComposition = [
[ThemeWrapperNext],
[ThemeWrapper],
[SystemContextProvider, { commandsManager, extensionManager, hotkeysManager, servicesManager }],
- [ToolboxProvider],
[ViewportGridProvider, { service: viewportGridService }],
// Other providers...
];
```
we now keep the state for toolbar inside the ToolbarService itself
### 3. Update tool option handlers to use onChange instead of commands
```diff
- <RowSegmentedControl
- key={option.id}
- option={option}
- />
+ <RowSegmentedControl
+ key={option.id}
+ option={option}
+ onChange={option.onChange}
+ />
```
@@ -13,18 +13,23 @@ interface RadioOption {
name: string;
value: string;
values: RadioValue[];
commands?: (val: string) => void;
onChange?: (val: string) => void;
}
interface RowSegmentedControlProps {
option: RadioOption;
className?: string;
onChange?: (val: string) => void;
}
export const RowSegmentedControl: React.FC<RowSegmentedControlProps> = ({ option, className }) => {
export const RowSegmentedControl: React.FC<RowSegmentedControlProps> = ({
option,
className,
onChange,
}) => {
const handleValueChange = (newVal: string) => {
if (option.commands) {
option.commands(newVal);
if (onChange) {
onChange(newVal);
}
};
@@ -56,7 +56,7 @@ const renderRangeSetting = option => {
maxValue={option.max}
step={option.step}
value={option.value}
onChange={value => option.commands?.(value)}
onChange={value => option.onChange?.(value)}
allowNumberEdit={true}
inputClassName="ml-1 w-4/5 cursor-pointer"
/>
@@ -70,6 +70,7 @@ const renderRadioSetting = option => {
<RowSegmentedControl
key={option.id}
option={option}
onChange={option.onChange}
/>
);
};
@@ -79,7 +80,7 @@ function renderDoubleRangeSetting(option) {
<RowDoubleRange
key={option.id}
values={option.value}
onChange={option.commands}
onChange={option.onChange}
minValue={option.min}
maxValue={option.max}
step={option.step}
@@ -1,162 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { ToolboxUI, useToolbox } from '../../';
import { useToolbar } from '@ohif/core';
/**
* A toolbox is a collection of buttons and commands that they invoke, used to provide
* custom control panels to users. This component is a generic UI component that
* interacts with services and commands in a generic fashion. While it might
* seem unconventional to import it from the UI and integrate it into the JSX,
* it belongs in the UI components as there isn't anything in this component that
* couldn't be used for a completely different type of app. It plays a crucial
* role in enhancing the app with a toolbox by providing a way to integrate
* and display various tools and their corresponding options
*/
function Toolbox({
servicesManager,
buttonSectionId,
commandsManager,
title,
...props
}: withAppTypes) {
// We should move these outside of the platform/ui-next, no file here
// should rely on the managers and services
const { state: toolboxState, api } = useToolbox(buttonSectionId);
const { onInteraction, toolbarButtons } = useToolbar({
servicesManager,
buttonSection: buttonSectionId,
});
const prevButtonIdsRef = useRef('');
const prevToolboxStateRef = useRef('');
useEffect(() => {
const currentButtonIdsStr = JSON.stringify(
toolbarButtons.map(button => {
const { id, componentProps } = button;
if (componentProps.items?.length) {
return componentProps.items.map(item => `${item.id}-${item.disabled}`);
}
return `${id}-${componentProps.disabled}`;
})
);
const currentToolBoxStateStr = JSON.stringify(
Object.keys(toolboxState.toolOptions).map(tool => {
const options = toolboxState.toolOptions[tool];
if (Array.isArray(options)) {
return options?.map(option => `${option.id}-${option.value}`);
}
})
);
if (
prevButtonIdsRef.current === currentButtonIdsStr &&
prevToolboxStateRef.current === currentToolBoxStateStr
) {
return;
}
prevButtonIdsRef.current = currentButtonIdsStr;
prevToolboxStateRef.current = currentToolBoxStateStr;
const initializeOptionsWithEnhancements = toolbarButtons.reduce(
(accumulator, toolbarButton) => {
const { id: buttonId, componentProps } = toolbarButton;
const createEnhancedOptions = (options, parentId) => {
const optionsToUse = Array.isArray(options) ? options : [options];
return optionsToUse.map(option => {
if (typeof option.optionComponent === 'function') {
return option;
}
const value =
toolboxState.toolOptions?.[parentId]?.find(prop => prop.id === option.id)?.value ??
option.value;
const updatedOptions = toolboxState.toolOptions?.[parentId];
return {
...option,
value,
commands: value => {
api.handleToolOptionChange(parentId, option.id, value);
const { isArray } = Array;
const cmds = isArray(option.commands) ? option.commands : [option.commands];
cmds.forEach(command => {
const isString = typeof command === 'string';
const isObject = typeof command === 'object';
const isFunction = typeof command === 'function';
if (isString) {
commandsManager.run(command, { value });
} else if (isObject) {
commandsManager.run({
...command,
commandOptions: {
...command.commandOptions,
...option,
value,
options: updatedOptions,
},
});
} else if (isFunction) {
command({ value, commandsManager, servicesManager, options: updatedOptions });
}
});
},
};
});
};
const { items, options } = componentProps;
if (items?.length) {
items.forEach(({ options, id }) => {
if (!options) {
return;
}
accumulator[id] = createEnhancedOptions(options, id);
});
} else if (options?.length) {
accumulator[buttonId] = createEnhancedOptions(options, buttonId);
} else if (options?.optionComponent) {
accumulator[buttonId] = options.optionComponent;
}
return accumulator;
},
{}
);
api.initializeToolOptions(initializeOptionsWithEnhancements);
}, [toolbarButtons, api, toolboxState, commandsManager, servicesManager]);
const handleToolOptionChange = (toolName, optionName, newValue) => {
api.handleToolOptionChange(toolName, optionName, newValue);
};
useEffect(() => {
return () => {
api.handleToolSelect(null);
};
}, []);
return (
<ToolboxUI
{...props}
title={title}
toolbarButtons={toolbarButtons}
toolboxState={toolboxState}
handleToolSelect={id => api.handleToolSelect(id)}
handleToolOptionChange={handleToolOptionChange}
onInteraction={onInteraction}
/>
);
}
export default Toolbox;
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React from 'react';
import classnames from 'classnames';
import { PanelSection } from '../../components';
@@ -6,52 +6,39 @@ import { ToolSettings } from '../OHIFToolSettings';
const ItemsPerRow = 4;
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
interface ToolbarButton {
id: string;
Component: React.ComponentType<{
id: string;
onInteraction: (details: { itemId: string }) => void;
size: string;
}>;
componentProps: {
isActive?: boolean;
buttonSection?: string;
options?: unknown;
};
}
/**
* Just refactoring from the toolbox component to make it more readable
*/
function ToolboxUI(props: withAppTypes) {
interface ToolboxProps {
toolbarButtons: ToolbarButton[];
numRows: number;
title?: string;
useCollapsedPanel?: boolean;
onInteraction?: (details: { itemId: string }) => void;
activeToolOptions?: unknown;
}
function ToolboxUI(props: ToolboxProps) {
const {
toolbarButtons,
handleToolSelect,
toolboxState,
toolbarButtons = [],
numRows,
servicesManager,
title,
useCollapsedPanel = true,
onInteraction,
activeToolOptions,
} = props;
const { activeTool, toolOptions, selectedEvent } = toolboxState;
const activeToolOptions = toolOptions?.[activeTool];
const prevToolOptions = usePrevious(activeToolOptions);
useEffect(() => {
if (!activeToolOptions || Array.isArray(activeToolOptions) === false) {
return;
}
activeToolOptions.forEach((option, index) => {
const prevOption = prevToolOptions ? prevToolOptions[index] : undefined;
if (!prevOption || option.value !== prevOption.value || selectedEvent) {
const isOptionValid = option.condition
? option.condition({ options: activeToolOptions })
: true;
if (isOptionValid) {
const { commands } = option;
commands(option.value);
}
}
});
}, [activeToolOptions, selectedEvent]);
const render = () => {
return (
<>
@@ -67,12 +54,9 @@ function ToolboxUI(props: withAppTypes) {
const toolClasses = `ml-1 ${isLastRow ? '' : 'mb-2'}`;
const onInteraction = ({ itemId, id, commands }) => {
const idToUse = itemId || id;
handleToolSelect(idToUse);
props.onInteraction({
const handleInteraction = ({ itemId }: { itemId: string }) => {
onInteraction?.({
itemId,
commands,
});
};
@@ -85,10 +69,8 @@ function ToolboxUI(props: withAppTypes) {
>
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
onInteraction={handleInteraction}
size="toolbox"
/>
</div>
@@ -1,3 +1 @@
import { ToolboxUI } from './ToolboxUI';
import Toolbox from './Toolbox';
export { ToolboxUI, Toolbox };
export * from './ToolboxUI';
@@ -41,7 +41,10 @@ const StudyItem = ({
<div className="flex min-w-0 flex-col items-start text-[13px]">
<Tooltip>
<TooltipContent>{date}</TooltipContent>
<TooltipTrigger className="w-full">
<TooltipTrigger
className="w-full"
asChild
>
<div className="h-[18px] w-full max-w-[160px] overflow-hidden truncate whitespace-nowrap text-left text-white">
{date}
</div>
@@ -49,7 +52,10 @@ const StudyItem = ({
</Tooltip>
<Tooltip>
<TooltipContent>{description}</TooltipContent>
<TooltipTrigger className="w-full">
<TooltipTrigger
className="w-full"
asChild
>
<div className="text-muted-foreground h-[18px] w-full overflow-hidden truncate whitespace-nowrap text-left">
{description}
</div>
@@ -50,7 +50,7 @@ function ToolButton(props: ToolButtonProps) {
className,
} = props;
const { buttonSizeClass, iconSizeClass } = sizeClasses[size];
const { buttonSizeClass, iconSizeClass } = sizeClasses[size] || sizeClasses.default;
const buttonClasses = cn(
baseClasses,
+3 -2
View File
@@ -52,7 +52,7 @@ import { ThumbnailList } from './ThumbnailList';
import { PanelSection } from './PanelSection';
import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip';
import { ToolboxUI, Toolbox } from './OHIFToolbox';
import { ToolboxUI } from './OHIFToolbox';
import Numeric from './Numeric';
import { InputDialog, PresetDialog } from './OHIFDialogs';
import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
@@ -105,6 +105,7 @@ import {
ToolButtonListItem,
ToolButtonListDivider,
} from './ToolButton';
import { ToolSettings } from './OHIFToolSettings';
export {
Numeric,
@@ -173,7 +174,6 @@ export {
PanelSection,
DisplaySetMessageListTooltip,
ToolboxUI,
Toolbox,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
@@ -236,4 +236,5 @@ export {
ImageModal,
UserPreferencesModal,
FooterAction,
ToolSettings,
};
@@ -1,109 +0,0 @@
import React, { createContext, useContext, useReducer } from 'react';
export const initialState = {};
export const toolboxReducer = (state, action) => {
const { toolbarSectionId } = action.payload;
if (!state[toolbarSectionId]) {
state[toolbarSectionId] = { activeTool: null, toolOptions: {}, selectedEvent: false };
}
switch (action.type) {
case 'SET_ACTIVE_TOOL':
return {
...state,
[toolbarSectionId]: {
...state[toolbarSectionId],
activeTool: action.payload.activeTool,
selectedEvent: true,
},
};
case 'UPDATE_TOOL_OPTION':
const { toolName, optionName, value } = action.payload;
return {
...state,
[toolbarSectionId]: {
...state[toolbarSectionId],
selectedEvent: false,
toolOptions: {
...state[toolbarSectionId].toolOptions,
[toolName]: state[toolbarSectionId].toolOptions[toolName].map(option =>
option.id === optionName ? { ...option, value } : option
),
},
},
};
case 'INITIALIZE_TOOL_OPTIONS':
// Initialize tool options for each toolbarSectionId
return {
...state,
selectedEvent: false,
[action.toolbarSectionId]: {
...state[action.toolbarSectionId],
toolOptions: action.payload,
},
};
default:
return state;
}
};
const ToolboxContext = createContext();
export const ToolboxProvider = ({ children }) => {
const [state, dispatch] = useReducer(toolboxReducer, initialState);
const handleToolSelect = (toolbarSectionId, toolName) => {
dispatch({
type: 'SET_ACTIVE_TOOL',
payload: { toolbarSectionId, activeTool: toolName },
});
};
const handleToolOptionChange = (toolbarSectionId, toolName, optionName, newValue) => {
dispatch({
type: 'UPDATE_TOOL_OPTION',
payload: { toolbarSectionId, toolName, optionName, value: newValue },
});
};
const initializeToolOptions = (toolbarSectionId, toolOptions) => {
dispatch({
type: 'INITIALIZE_TOOL_OPTIONS',
toolbarSectionId,
payload: toolOptions,
});
};
const api = { handleToolSelect, handleToolOptionChange, initializeToolOptions };
const value = { state, api };
return <ToolboxContext.Provider value={value}>{children}</ToolboxContext.Provider>;
};
/**
* Custom hook for accessing toolbox state and actions for a specific toolbar section.
* You can use this hook to access the state and actions for a specific toolbar section (
* defined by the toolbarSectionId) in your custom toolbar components. This hook
* helps to manage the state and actions for the tools and their options in the toolbar.
*/
export const useToolbox = toolbarSectionId => {
const context = useContext(ToolboxContext);
if (context === undefined) {
throw new Error('useToolbox must be used within a ToolboxProvider');
}
const { state, api } = context;
return {
state: state[toolbarSectionId] || { activeTool: null, toolOptions: {} },
api: {
handleToolSelect: toolName => api.handleToolSelect(toolbarSectionId, toolName),
handleToolOptionChange: (toolName, optionName, value) =>
api.handleToolOptionChange(toolbarSectionId, toolName, optionName, value),
initializeToolOptions: toolOptions =>
api.initializeToolOptions(toolbarSectionId, toolOptions),
},
};
};
@@ -1,13 +1,11 @@
import NotificationProvider, { useNotification } from './NotificationProvider';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider';
import { ToolboxProvider, useToolbox } from './ToolboxContext';
import { ModalProvider, useModal } from './ModalProvider';
import { DialogProvider, useDialog } from './DialogProvider';
import ManagedDialog from './ManagedDialog';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
export { ToolboxProvider, useToolbox };
export { ModalProvider, useModal };
export { DialogProvider, useDialog };
export { ManagedDialog };
+2 -6
View File
@@ -33,6 +33,7 @@ import {
PanelSection,
DisplaySetMessageListTooltip,
ToolboxUI,
ToolSettings,
DoubleSlider,
Label,
Slider,
@@ -98,7 +99,6 @@ import {
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
Toolbox,
Numeric,
InputDialog,
PresetDialog,
@@ -113,8 +113,6 @@ import { DataRow } from './components/DataRow';
import {
useNotification,
NotificationProvider,
useToolbox,
ToolboxProvider,
useModal,
ModalProvider,
DialogProvider,
@@ -232,9 +230,6 @@ export {
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
ToolboxProvider,
Toolbox,
useToolbox,
utils,
Numeric,
AboutModal,
@@ -249,4 +244,5 @@ export {
DialogProvider,
useDialog,
ManagedDialog,
ToolSettings,
};