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

@@ -60,7 +60,6 @@ export default class ContextMenuController {
return;
}
console.log('Getting items from', menus);
const items = ContextMenuItemsBuilder.getMenuItems(
selectorProps || contextMenuProps,
event,
@@ -70,8 +70,6 @@ export function findMenu(menus: Menu[], props?: Types.IProps, menuIdFilter?: str
current = findIt.next();
}
console.log('Menu chosen', menu?.id || 'NONE');
return menu;
}
@@ -5,6 +5,11 @@
function getImageSrcFromImageId(cornerstone, imageId) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
// Note: the default width and height of the canvas is 300x150
// but we need to set the width and height to the same number since
// the thumbnails are usually square and we want to maintain the aspect ratio
canvas.width = 128 / window.devicePixelRatio;
canvas.height = 128 / window.devicePixelRatio;
cornerstone.utilities
.loadImageToCanvas({ canvas, imageId })
.then(imageId => {
+39 -43
View File
@@ -1,57 +1,53 @@
import React, { useCallback, useEffect, useState } from 'react';
import React from 'react';
import { Tooltip } from '@ohif/ui';
import classnames from 'classnames';
import { useViewportGrid } from '@ohif/ui';
import { useToolbar } from '@ohif/core';
export default function Toolbar({
servicesManager,
}: Types.Extensions.ExtensionParams): React.ReactElement {
const { toolbarService } = servicesManager.services;
export function Toolbar({ servicesManager }) {
const { toolbarButtons, onInteraction } = useToolbar({
servicesManager,
buttonSection: 'primary',
});
const [viewportGrid, viewportGridService] = useViewportGrid();
const [toolbarButtons, setToolbarButtons] = useState([]);
useEffect(() => {
const updateToolbar = () => {
const toolGroupId =
viewportGridService.getActiveViewportOptionByKey('toolGroupId') ?? 'default';
setToolbarButtons(toolbarService.getButtonSection(toolGroupId));
};
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_MODIFIED,
updateToolbar
);
updateToolbar();
return () => {
unsubscribe();
};
}, [toolbarService, viewportGrid]);
const onInteraction = useCallback(
args => toolbarService.recordInteraction(args),
[toolbarService]
);
if (!toolbarButtons.length) {
return null;
}
return (
<>
{toolbarButtons.map(toolDef => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
return (
// The margin for separating the tools on the toolbar should go here and NOT in each individual component (button) item.
// This allows for the individual items to be included in other UI components where perhaps alternative margins are desired.
const { disabled } = componentProps;
const tool = (
<Component
key={id}
id={id}
onInteraction={onInteraction}
servicesManager={servicesManager}
{...componentProps}
/>
);
return disabled ? (
<Tooltip
key={id}
position="bottom"
content={componentProps.label}
secondaryContent={'Not available on the current viewport'}
>
<div className={classnames('mr-1')}>{tool}</div>
</Tooltip>
) : (
<div
key={id}
className={classnames('mr-1')}
className="mr-1"
>
<Component
id={id}
{...componentProps}
onInteraction={onInteraction}
servicesManager={servicesManager}
/>
{tool}
</div>
);
})}
@@ -0,0 +1,35 @@
import { ToolbarButton, ButtonGroup } from '@ohif/ui';
import React, { useCallback } from 'react';
function ToolbarButtonGroupWithServices({ groupId, items, onInteraction, size }) {
const getSplitButtonItems = useCallback(
items =>
items.map((item, index) => (
<ToolbarButton
key={item.id}
icon={item.icon}
label={item.label}
disabled={item.disabled}
className={item.className}
id={item.id}
size={size}
onClick={() => {
onInteraction({
groupId,
itemId: item.id,
commands: item.commands,
});
}}
// Note: this is necessary since tooltip will add
// default styles to the tooltip container which
// we don't want for groups
toolTipClassName=""
/>
)),
[onInteraction, groupId]
);
return <ButtonGroup>{getSplitButtonItems(items)}</ButtonGroup>;
}
export default ToolbarButtonGroupWithServices;
@@ -1,75 +0,0 @@
import { ToolbarButton } from '@ohif/ui';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
function ToolbarButtonWithServices({
id,
type,
commands,
onInteraction,
servicesManager,
...props
}) {
const { toolbarService } = servicesManager?.services || {};
const [buttonsState, setButtonState] = useState({
primaryToolId: '',
toggles: {},
groups: {},
});
const { primaryToolId } = buttonsState;
const isActive =
(type === 'tool' && id === primaryToolId) ||
(type === 'toggle' && buttonsState.toggles[id] === true);
useEffect(() => {
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
state => {
setButtonState({ ...state });
}
);
return () => {
unsubscribe();
};
}, [toolbarService]);
return (
<ToolbarButton
commands={commands}
id={id}
type={type}
isActive={isActive}
onInteraction={onInteraction}
{...props}
/>
);
}
ToolbarButtonWithServices.propTypes = {
id: PropTypes.string.isRequired,
type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired,
commands: PropTypes.arrayOf(
PropTypes.shape({
commandName: PropTypes.string.isRequired,
context: PropTypes.string,
})
),
onInteraction: PropTypes.func.isRequired,
servicesManager: PropTypes.shape({
services: PropTypes.shape({
toolbarService: PropTypes.shape({
subscribe: PropTypes.func.isRequired,
state: PropTypes.shape({
primaryToolId: PropTypes.string,
toggles: PropTypes.objectOf(PropTypes.bool),
groups: PropTypes.objectOf(PropTypes.any),
}).isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
export default ToolbarButtonWithServices;
@@ -3,21 +3,20 @@ import PropTypes from 'prop-types';
import { LayoutSelector as OHIFLayoutSelector, ToolbarButton } from '@ohif/ui';
import { ServicesManager } from '@ohif/core';
function ToolbarLayoutSelectorWithServices({ servicesManager, ...props }) {
const { toolbarService } = servicesManager.services;
function ToolbarLayoutSelectorWithServices({ servicesManager, commands, ...props }) {
const { toolbarService, viewportGridService } = servicesManager.services;
const onSelection = useCallback(
props => {
toolbarService.recordInteraction({
interactionType: 'action',
commands: [
{
commandName: 'setViewportGridLayout',
commandOptions: { ...props },
context: 'DEFAULT',
},
],
});
const onInteraction = useCallback(
args => {
const viewportId = viewportGridService.getActiveViewportId();
const refreshProps = {
viewportId,
};
if (props.onLayoutChange) {
props.onLayoutChange(args);
}
toolbarService.recordInteraction({ commands }, { ...args, refreshProps });
},
[toolbarService]
);
@@ -25,12 +24,12 @@ function ToolbarLayoutSelectorWithServices({ servicesManager, ...props }) {
return (
<LayoutSelector
{...props}
onSelection={onSelection}
onInteraction={onInteraction}
/>
);
}
function LayoutSelector({ rows, columns, className, onSelection, ...rest }) {
function LayoutSelector({ rows, columns, className, onInteraction, ...rest }) {
const [isOpen, setIsOpen] = useState(false);
const closeOnOutsideClick = () => {
@@ -62,7 +61,7 @@ function LayoutSelector({ rows, columns, className, onSelection, ...rest }) {
<DropdownContent
rows={rows}
columns={columns}
onSelection={onSelection}
onSelection={onInteraction}
/>
)
}
@@ -1,11 +1,8 @@
import { SplitButton, Icon, ToolbarButton } from '@ohif/ui';
import React, { useEffect, useState } from 'react';
import { SplitButton, ToolbarButton } from '@ohif/ui';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
function ToolbarSplitButtonWithServices({
isRadio,
isAction,
groupId,
primary,
secondary,
@@ -16,123 +13,35 @@ function ToolbarSplitButtonWithServices({
}) {
const { toolbarService } = servicesManager?.services;
const handleItemClick = (item, index) => {
const { id, type, commands } = item;
onInteraction({
groupId,
itemId: id,
interactionType: type,
commands,
});
setState(state => ({
...state,
primary: !isAction && isRadio ? { ...item, index } : state.primary,
isExpanded: false,
items: getSplitButtonItems(items).filter(item =>
isRadio && !isAction ? item.index !== index : true
),
}));
};
/* Bubbles up individual item clicks */
const getSplitButtonItems = items =>
items.map((item, index) => ({
...item,
index,
onClick: () => handleItemClick(item, index),
}));
const [buttonsState, setButtonState] = useState({
primaryToolId: '',
toggles: {},
groups: {},
});
const [state, setState] = useState({
primary,
items: getSplitButtonItems(items).filter(item =>
isRadio && !isAction ? item.id !== primary.id : true
),
});
const { primaryToolId, toggles } = buttonsState;
const isPrimaryToggle = state.primary.type === 'toggle';
const isPrimaryActive =
(state.primary.type === 'tool' && primaryToolId === state.primary.id) ||
(isPrimaryToggle && toggles[state.primary.id] === true);
const getSplitButtonItems = useCallback(
items =>
items.map((item, index) => ({
...item,
index,
onClick: () => {
onInteraction({
groupId,
itemId: item.id,
commands: item.commands,
});
},
})),
[]
);
const PrimaryButtonComponent =
toolbarService?.getButtonComponentForUIType(state.primary.uiType) ?? ToolbarButton;
toolbarService?.getButtonComponentForUIType(primary.uiType) ?? ToolbarButton;
useEffect(() => {
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
state => {
setButtonState({ ...state });
}
);
return () => {
unsubscribe();
};
}, [toolbarService]);
const updatedItems = state.items.map(item => {
const isActive = item.type === 'tool' && primaryToolId === item.id;
// We could have added the
// item.type === 'toggle' && toggles[item.id] === true
// too but that makes the button active when the toggle is active under it
// which feels weird
return {
...item,
isActive,
};
});
const DefaultListItemRenderer = ({ type, icon, label, t, id }) => {
const isActive = type === 'toggle' && toggles[id] === true;
return (
<div
className={classNames(
'hover:bg-primary-dark flex h-8 w-full flex-row items-center p-3',
'whitespace-pre text-base',
isActive && 'bg-primary-dark',
isActive
? 'text-[#348CFD]'
: 'text-common-bright hover:bg-primary-dark hover:text-primary-light'
)}
>
{icon && (
<span className="mr-4">
<Icon
name={icon}
className="h-5 w-5"
/>
</span>
)}
<span className="mr-5">{t(label)}</span>
</div>
);
};
const listItemRenderer = renderer || DefaultListItemRenderer;
const listItemRenderer = renderer;
return (
<SplitButton
isRadio={isRadio}
isAction={isAction}
primary={state.primary}
primary={primary}
secondary={secondary}
items={updatedItems}
items={getSplitButtonItems(items)}
groupId={groupId}
renderer={listItemRenderer}
isActive={isPrimaryActive || updatedItems.some(item => item.isActive)}
isToggle={isPrimaryToggle}
onInteraction={onInteraction}
Component={props => (
<PrimaryButtonComponent
@@ -146,11 +55,9 @@ function ToolbarSplitButtonWithServices({
ToolbarSplitButtonWithServices.propTypes = {
isRadio: PropTypes.bool,
isAction: PropTypes.bool,
groupId: PropTypes.string,
primary: PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired,
uiType: PropTypes.string,
}),
secondary: PropTypes.shape({
@@ -158,15 +65,17 @@ ToolbarSplitButtonWithServices.propTypes = {
icon: PropTypes.string.isRequired,
label: PropTypes.string,
tooltip: PropTypes.string.isRequired,
isActive: PropTypes.bool,
disabled: PropTypes.bool,
className: PropTypes.string,
}),
items: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired,
icon: PropTypes.string,
label: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
className: PropTypes.string,
})
),
renderer: PropTypes.func,
@@ -7,7 +7,7 @@ import { ErrorBoundary, UserPreferences, AboutModal, Header, useModal } from '@o
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import { useAppConfig } from '@state';
import Toolbar from '../Toolbar/Toolbar';
import { Toolbar } from '../Toolbar/Toolbar';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
+4 -12
View File
@@ -48,22 +48,13 @@ function ViewerLayout({
const getComponent = id => {
const entry = extensionManager.getModuleEntry(id);
if (!entry) {
if (!entry || !entry.component) {
throw new Error(
`${id} is not valid for an extension module. Please verify your configuration or ensure that the extension is properly registered. It's also possible that your mode is utilizing a module from an extension that hasn't been included in its dependencies (add the extension to the "extensionDependencies" array in your mode's index.js file)`
`${id} is not valid for an extension module or no component found from extension ${id}. Please verify your configuration or ensure that the extension is properly registered. It's also possible that your mode is utilizing a module from an extension that hasn't been included in its dependencies (add the extension to the "extensionDependencies" array in your mode's index.js file). Check the reference string to the extension in your Mode configuration`
);
}
let content;
if (entry && entry.component) {
content = entry.component;
} else {
throw new Error(
`No component found from extension ${id}. Check the reference string to the extension in your Mode configuration`
);
}
return { entry, content };
return { entry, content: entry.component };
};
const getPanelData = id => {
@@ -76,6 +67,7 @@ function ViewerLayout({
label: entry.label,
name: entry.name,
content,
contexts: entry.contexts,
};
};
+1 -79
View File
@@ -18,6 +18,7 @@ export type HangingProtocolParams = {
stageIndex?: number;
activeStudyUID?: string;
stageId?: string;
reset?: false;
};
export type UpdateViewportDisplaySetParams = {
@@ -107,41 +108,6 @@ const commandsModule = ({
measurementService.clear();
},
/**
* Toggles off all tools which contain a commandName of setHangingProtocol
* or toggleHangingProtocol, and which match/don't match the protocol id/stage
*/
toggleHpTools: () => {
const {
protocol,
stageIndex: toggleStageIndex,
stage,
} = hangingProtocolService.getActiveProtocol();
const enableListener = button => {
if (!button.id) {
return;
}
const { commands, items, primary } = button.props || button;
if (primary) {
enableListener(primary);
}
if (items) {
items.forEach(enableListener);
}
const hpCommand = commands?.find?.(isHangingProtocolCommand);
if (!hpCommand) {
return;
}
const { protocolId, stageIndex, stageId } = hpCommand.commandOptions;
const isActive =
(!protocolId || protocolId === protocol.id) &&
(stageIndex === undefined || stageIndex === toggleStageIndex) &&
(!stageId || stageId === stage.id);
toolbarService.setToggled(button.id, isActive);
};
Object.values(toolbarService.getButtons()).forEach(enableListener);
},
/**
* Sets the specified protocol
* 1. Records any existing state using the viewport grid service
@@ -173,14 +139,12 @@ const commandsModule = ({
stageIndex,
reset = false,
}: HangingProtocolParams): boolean => {
const primaryToolBeforeHPChange = toolbarService.getActivePrimaryTool();
try {
// Stores in the state the display set selector id to displaySetUID mapping
// Pass in viewportId for the active viewport. This item will get set as
// the activeViewportId
const state = viewportGridService.getState();
const hpInfo = hangingProtocolService.getState();
const { protocol: oldProtocol } = hangingProtocolService.getActiveProtocol();
const stateSyncReduce = reuseCachedLayouts(state, hangingProtocolService, stateSyncService);
const { hangingProtocolStageIndexMap, viewportGridStore, displaySetSelectorMap } =
stateSyncReduce;
@@ -243,33 +207,9 @@ const commandsModule = ({
`${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0`
];
stateSyncService.store(stateSyncReduce);
// This is a default action applied
actions.toggleHpTools();
// try to use the same tool in the new hanging protocol stage
const primaryButton = toolbarService.getButton(primaryToolBeforeHPChange);
if (primaryButton) {
// is there any type of interaction on this button, if not it might be in the
// items. This is a bit of a hack, but it works for now.
let interactionType = primaryButton.props?.interactionType;
if (!interactionType && primaryButton.props?.items) {
const firstItem = primaryButton.props.items[0];
interactionType = firstItem.props?.interactionType || firstItem.props?.type;
}
if (interactionType) {
toolbarService.recordInteraction({
interactionType,
...primaryButton.props,
});
}
}
return true;
} catch (e) {
console.error(e);
actions.toggleHpTools();
uiNotificationService.show({
title: 'Apply Hanging Protocol',
message: 'The hanging protocol could not be applied.',
@@ -639,56 +579,38 @@ const commandsModule = ({
},
clearMeasurements: {
commandFn: actions.clearMeasurements,
storeContexts: [],
options: {},
},
displayNotification: {
commandFn: actions.displayNotification,
storeContexts: [],
options: {},
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
storeContexts: [],
options: {},
},
toggleHangingProtocol: {
commandFn: actions.toggleHangingProtocol,
storeContexts: [],
options: {},
},
navigateHistory: {
commandFn: actions.navigateHistory,
storeContexts: [],
options: {},
},
nextStage: {
commandFn: actions.deltaStage,
storeContexts: [],
options: { direction: 1 },
},
previousStage: {
commandFn: actions.deltaStage,
storeContexts: [],
options: { direction: -1 },
},
setViewportGridLayout: {
commandFn: actions.setViewportGridLayout,
storeContexts: [],
options: {},
},
toggleOneUp: {
commandFn: actions.toggleOneUp,
storeContexts: [],
options: {},
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
updateViewportDisplaySet: {
commandFn: actions.updateViewportDisplaySet,
storeContexts: [],
options: {},
},
};
@@ -93,8 +93,8 @@ export default function getCustomizationModule({ servicesManager, extensionManag
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
? this.contentF(props)
: null;
if (!value) {
return null;
}
+45 -17
View File
@@ -1,39 +1,67 @@
import ToolbarDivider from './Toolbar/ToolbarDivider';
import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector';
import ToolbarSplitButtonWithServices from './Toolbar/ToolbarSplitButtonWithServices';
import ToolbarButtonWithServices from './Toolbar/ToolbarButtonWithServices';
import ToolbarButtonGroupWithServices from './Toolbar/ToolbarButtonGroupWithServices';
import { ToolbarButton } from '@ohif/ui';
const getClassName = isToggled => {
return {
className: isToggled
? '!text-primary-active'
: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
};
export default function getToolbarModule({ commandsManager, servicesManager }) {
const { cineService } = servicesManager.services;
return [
{
name: 'ohif.radioGroup',
defaultComponent: ToolbarButton,
},
{
name: 'ohif.divider',
defaultComponent: ToolbarDivider,
clickHandler: () => {},
},
{
name: 'ohif.action',
defaultComponent: ToolbarButtonWithServices,
clickHandler: () => {},
},
{
name: 'ohif.radioGroup',
defaultComponent: ToolbarButtonWithServices,
clickHandler: () => {},
},
{
name: 'ohif.splitButton',
defaultComponent: ToolbarSplitButtonWithServices,
clickHandler: () => {},
},
{
name: 'ohif.layoutSelector',
defaultComponent: ToolbarLayoutSelectorWithServices,
clickHandler: (evt, clickedBtn, btnSectionName) => {},
},
{
name: 'ohif.toggle',
defaultComponent: ToolbarButtonWithServices,
clickHandler: () => {},
name: 'ohif.buttonGroup',
defaultComponent: ToolbarButtonGroupWithServices,
},
{
name: 'evaluate.group.promoteToPrimary',
evaluate: ({ viewportId, button, itemId }) => {
const { items } = button.props;
if (!itemId) {
return {
primary: button.props.primary,
items,
};
}
// other wise we can move the clicked tool to the primary button
const clickedItemProps = items.find(item => item.id === itemId || item.itemId === itemId);
return {
primary: clickedItemProps,
items,
};
},
},
{
name: 'evaluate.cine',
evaluate: () => {
const isToggled = cineService.getState().isCineEnabled;
return getClassName(isToggled);
},
},
];
}
+51 -3
View File
@@ -10,8 +10,13 @@ const metadataProvider = classes.MetadataProvider;
* @param {Object} servicesManager
* @param {Object} configuration
*/
export default function init({ servicesManager, configuration = {} }): void {
const { stateSyncService } = servicesManager.services;
export default function init({ servicesManager, configuration = {}, commandsManager }): void {
const { stateSyncService, toolbarService, cineService, viewportGridService } =
servicesManager.services;
toolbarService.registerEventForToolbarUpdate(cineService, [
cineService.EVENTS.CINE_STATE_CHANGED,
]);
// Add
DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.INSTANCES_ADDED, handlePETImageMetadata);
@@ -24,6 +29,10 @@ export default function init({ servicesManager, configuration = {} }): void {
// Used to recover manual changes to the layout of a stage.
stateSyncService.register('viewportGridStore', { clearOnModeExit: true });
// uiStateStore is a sync state which stores the relevant
// UI state for the viewer
stateSyncService.register('uiStateStore', { clearOnModeExit: true });
// displaySetSelectorMap stores a map from
// `<activeStudyUID>:<displaySetSelectorId>:<matchOffset>` to
// a displaySetInstanceUID, used to display named display sets in
@@ -38,7 +47,7 @@ export default function init({ servicesManager, configuration = {} }): void {
});
// Stores a map from the to be applied hanging protocols `<activeStudyUID>:<protocolId>`
// to the previously applied hanging protolStageIndexMap key, in order to toggle
// to the previously applied hanging protocolStageIndexMap key, in order to toggle
// off the applied protocol and remember the old state.
stateSyncService.register('toggleHangingProtocol', { clearOnModeExit: true });
@@ -46,6 +55,45 @@ export default function init({ servicesManager, configuration = {} }): void {
// changes numRows and numCols, the viewports can be remembers and then replaced
// afterwards.
stateSyncService.register('viewportsByPosition', { clearOnModeExit: true });
// Function to process and subscribe to events for a given set of commands and listeners
const subscribeToEvents = listeners => {
Object.entries(listeners).forEach(([event, commands]) => {
const supportedEvents = [
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
viewportGridService.EVENTS.VIEWPORTS_READY,
];
if (supportedEvents.includes(event)) {
viewportGridService.subscribe(event, eventData => {
const viewportId = eventData?.viewportId ?? viewportGridService.getActiveViewportId();
commandsManager.run(commands, { viewportId });
});
}
});
};
toolbarService.subscribe(toolbarService.EVENTS.TOOL_BAR_MODIFIED, state => {
const { buttons } = state;
for (const [id, button] of Object.entries(buttons)) {
const { groupId, items, listeners } = button.props;
// Handle group items' listeners
if (groupId && items) {
items.forEach(item => {
if (item.listeners) {
subscribeToEvents(item.listeners);
}
});
}
// Handle button listeners
if (listeners) {
subscribeToEvents(listeners);
}
}
});
}
const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => {