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

@@ -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,
};