feat(ui-next): Adds toggle state for ToolButton and Crosshair example (#5914)

Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Dan Rukas 2026-04-30 10:13:58 -04:00 committed by GitHub
parent e6816f6de4
commit 691e26731f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 373 additions and 33 deletions

View File

@ -145,7 +145,21 @@ function commandsModule({
function _getActiveViewportToolGroupId() {
const viewport = _getActiveViewportEnabledElement();
return toolGroupService.getToolGroupForViewport(viewport.id);
const toolGroup = viewport && toolGroupService.getToolGroupForViewport(viewport.id);
return toolGroup?.id;
}
function _usesPrimaryActivation(bindings) {
if (!bindings?.length) {
return true;
}
return bindings.some(
binding =>
binding.mouseButton === Enums.MouseBindings.Primary &&
binding.modifierKey == null &&
binding.numTouchPoints == null
);
}
function _getActiveSegmentationInfo() {
@ -1017,32 +1031,48 @@ function commandsModule({
toolIsEnabled ? toolGroup.setToolDisabled(toolName) : toolGroup.setToolEnabled(toolName);
},
toggleActiveDisabledToolbar({ value, itemId, toolGroupId }) {
toggleActiveDisabledToolbar({ value, itemId, toolGroupId, toolGroupIds }) {
const toolName = itemId || value;
toolGroupId = toolGroupId ?? _getActiveViewportToolGroupId();
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return;
}
const resolvedToolGroupIds = toolGroupIds?.length
? toolGroupIds
: [toolGroupId ?? _getActiveViewportToolGroupId()];
const toolIsActive = [
Enums.ToolModes.Active,
Enums.ToolModes.Enabled,
Enums.ToolModes.Passive,
].includes(toolGroup.getToolOptions(toolName).mode);
toolIsActive
? toolGroup.setToolDisabled(toolName)
: actions.setToolActive({ toolName, toolGroupId });
// we should set the previously active tool to active after we set the
// current tool disabled
if (toolIsActive) {
const prevToolName = toolGroup.getPrevActivePrimaryToolName();
if (prevToolName !== toolName) {
actions.setToolActive({ toolName: prevToolName, toolGroupId });
resolvedToolGroupIds.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return;
}
}
const toolIsActive = [
Enums.ToolModes.Active,
Enums.ToolModes.Enabled,
Enums.ToolModes.Passive,
].includes(toolGroup.getToolOptions(toolName).mode);
if (toolIsActive) {
toolGroup.setToolDisabled(toolName);
const bindings = toolGroupService.getToolBindings(toolGroupId, toolName);
if (_usesPrimaryActivation(bindings)) {
// we should set the previously active tool to active after we set the
// current tool disabled
const prevToolName = toolGroup.getPrevActivePrimaryToolName();
if (prevToolName !== toolName) {
actions.setToolActive({ toolName: prevToolName, toolGroupId });
}
}
return;
}
const bindings = toolGroupService.getToolBindings(toolGroupId, toolName);
if (_usesPrimaryActivation(bindings)) {
actions.setToolActive({ toolName, toolGroupId, bindings });
} else {
toolGroup.setToolActive(toolName, { bindings });
}
});
},
setToolActiveToolbar: ({ value, itemId, toolName, toolGroupIds = [], bindings }) => {
// Sometimes it is passed as value (tools with options), sometimes as itemId (toolbar buttons)

View File

@ -429,6 +429,40 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
};
},
},
{
name: 'evaluate.cornerstoneTool.toggleWithModifier',
evaluate: ({ viewportId, button, disabledText, toggledOnIcon, defaultIcon }) => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
const toolName = toolbarService.getToolNameForButton(button);
if (!toolGroup.hasTool(toolName)) {
return getDisabledState(disabledText);
}
const { mode } = toolGroup.getToolOptions(toolName) ?? {};
const isToggled =
mode === Enums.ToolModes.Passive ||
mode === Enums.ToolModes.Active ||
mode === Enums.ToolModes.Enabled;
const toolBindings = toolGroupService.getToolBindings(toolGroup.id, toolName);
const hasModifierKey =
toolBindings?.some(binding => binding.modifierKey != null) ?? false;
return {
disabled: false,
isActive: false,
isToggled,
icon:
isToggled && hasModifierKey && toggledOnIcon
? toggledOnIcon
: defaultIcon ?? button.props.icon,
};
},
},
{
name: 'evaluate.action',
evaluate: () => {

View File

@ -36,7 +36,9 @@ export default class ToolGroupService {
cornerstoneViewportService: any;
viewportGridService: any;
uiNotificationService: any;
customizationService: any;
private toolGroupIds: Set<string> = new Set();
private toolBindingsMap: Map<string, Map<string, Array<Record<string, unknown>>>> = new Map();
/**
* Service-specific
*/
@ -44,11 +46,17 @@ export default class ToolGroupService {
EVENTS: { [key: string]: string };
constructor(servicesManager: AppTypes.ServicesManager) {
const { cornerstoneViewportService, viewportGridService, uiNotificationService } =
const {
cornerstoneViewportService,
viewportGridService,
uiNotificationService,
customizationService,
} =
servicesManager.services;
this.cornerstoneViewportService = cornerstoneViewportService;
this.viewportGridService = viewportGridService;
this.uiNotificationService = uiNotificationService;
this.customizationService = customizationService;
this.listeners = {};
this.EVENTS = EVENTS;
Object.assign(this, pubSubServiceInterface);
@ -122,6 +130,7 @@ export default class ToolGroupService {
public destroy(): void {
ToolGroupManager.destroy();
this.toolGroupIds = new Set();
this.toolBindingsMap.clear();
eventTarget.removeEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated);
}
@ -198,6 +207,7 @@ export default class ToolGroupService {
// this.changeConfigurationIfNecessary(toolGroup, volumeId);
this._addTools(toolGroup, tools, configs);
this._setToolsMode(toolGroup, tools);
this._loadPersistedBindings(toolGroupId);
}
public createToolGroupAndAddTools(toolGroupId: string, tools: Array<Tool>): Types.IToolGroup {
@ -242,29 +252,92 @@ export default class ToolGroupService {
return this.getToolGroup(toolGroupId)?.getActivePrimaryMouseButtonTool();
}
public getToolBindings(
toolGroupId: string,
toolName: string
): Array<Record<string, unknown>> | undefined {
return this.toolBindingsMap.get(toolGroupId)?.get(toolName);
}
public setToolBindings(
toolGroupId: string,
toolName: string,
bindings: Array<Record<string, unknown>>
): void {
if (!this.toolBindingsMap.has(toolGroupId)) {
this.toolBindingsMap.set(toolGroupId, new Map());
}
this.toolBindingsMap.get(toolGroupId).set(toolName, bindings);
}
public applyToolBindings(toolGroupId: string, toolName: string): void {
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return;
}
const bindings = this.getToolBindings(toolGroupId, toolName);
if (!bindings) {
return;
}
const { mode } = toolGroup.getToolOptions(toolName);
if (
mode === Enums.ToolModes.Active ||
mode === Enums.ToolModes.Passive ||
mode === Enums.ToolModes.Enabled
) {
toolGroup.setToolActive(toolName, { bindings });
}
}
public getAllToolBindings(): Array<{
toolGroupId: string;
toolName: string;
bindings: Array<Record<string, unknown>>;
}> {
const result = [];
for (const [toolGroupId, toolMap] of this.toolBindingsMap) {
for (const [toolName, bindings] of toolMap) {
result.push({ toolGroupId, toolName, bindings });
}
}
return result;
}
private _setToolsMode(toolGroup, tools) {
const { active, passive, enabled, disabled } = tools;
if (active) {
active.forEach(({ toolName, bindings }) => {
if (bindings) {
this.setToolBindings(toolGroup.id, toolName, bindings);
}
toolGroup.setToolActive(toolName, { bindings });
});
}
if (passive) {
passive.forEach(({ toolName }) => {
passive.forEach(({ toolName, bindings }) => {
if (bindings) {
this.setToolBindings(toolGroup.id, toolName, bindings);
}
toolGroup.setToolPassive(toolName);
});
}
if (enabled) {
enabled.forEach(({ toolName }) => {
enabled.forEach(({ toolName, bindings }) => {
if (bindings) {
this.setToolBindings(toolGroup.id, toolName, bindings);
}
toolGroup.setToolEnabled(toolName);
});
}
if (disabled) {
disabled.forEach(({ toolName }) => {
disabled.forEach(({ toolName, bindings }) => {
if (bindings) {
this.setToolBindings(toolGroup.id, toolName, bindings);
}
toolGroup.setToolDisabled(toolName);
});
}
@ -300,6 +373,35 @@ export default class ToolGroupService {
}
}
private _loadPersistedBindings(toolGroupId: string): void {
try {
const stored = localStorage.getItem(this._getToolBindingsStorageKey());
if (!stored) {
return;
}
const parsed = JSON.parse(stored);
const toolGroupBindings = parsed[toolGroupId];
if (!toolGroupBindings) {
return;
}
for (const [toolName, bindings] of Object.entries(toolGroupBindings)) {
this.setToolBindings(toolGroupId, toolName, bindings as Array<Record<string, unknown>>);
}
} catch {
// ignore corrupt localStorage
}
}
private _getToolBindingsStorageKey(): string {
const customizationValue = this.customizationService?.getCustomization(
'ohif.userPreferences.toolBindingsStorageKey'
);
return typeof customizationValue === 'string' && customizationValue.length > 0
? customizationValue
: 'user-preferred-tool-bindings';
}
private _onToolActivated = (evt: Types.EventTypes.ToolActivatedEventType) => {
const { toolGroupId, toolName, toolBindingsOptions } = evt.detail;
const isPrimaryTool = toolBindingsOptions.bindings?.some(

View File

@ -17,9 +17,46 @@ interface HotkeyDefinitions {
[key: string]: HotkeyDefinition;
}
const MODIFIER_OPTIONS = [
{ value: '16', label: 'Shift' },
{ value: '17', label: 'Ctrl' },
{ value: '18', label: 'Alt' },
{ value: '91', label: 'Meta' },
];
const DEFAULT_TOOL_BINDINGS_STORAGE_KEY = 'user-preferred-tool-bindings';
function getToolBindingsStorageKey(customizationService: any): string {
const customizationValue = customizationService?.getCustomization(
'ohif.userPreferences.toolBindingsStorageKey'
);
return typeof customizationValue === 'string' && customizationValue.length > 0
? customizationValue
: DEFAULT_TOOL_BINDINGS_STORAGE_KEY;
}
function getToolModifier(toolGroupService: any, toolGroupId: string, toolName: string): string | null {
if (!toolGroupService) {
return null;
}
const bindings = toolGroupService.getToolBindings(toolGroupId, toolName);
if (!bindings?.length) {
return null;
}
const modifierBinding = bindings.find(
binding => binding.modifierKey != null && binding.numTouchPoints == null
);
return modifierBinding?.modifierKey != null ? String(modifierBinding.modifierKey) : null;
}
function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
const { hotkeysManager } = useSystem();
const { hotkeysManager, servicesManager } = useSystem();
const { t, i18n: i18nextInstance } = useTranslation('UserPreferencesModal');
const toolGroupService = (servicesManager as any)?.services?.toolGroupService;
const customizationService = (servicesManager as any)?.services?.customizationService;
const toolBindingsStorageKey = getToolBindingsStorageKey(customizationService);
const { hotkeyDefinitions = {}, hotkeyDefaults = {} } = hotkeysManager;
@ -51,9 +88,15 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
const currentLanguage = currentLanguageFn();
const initialCrosshairModifier = useMemo(
() => getToolModifier(toolGroupService, 'mpr', 'Crosshairs'),
[toolGroupService]
);
const [state, setState] = useState({
hotkeyDefinitions: initialHotkeyDefinitions,
languageValue: currentLanguage.value,
crosshairModifier: initialCrosshairModifier,
});
const onLanguageChangeHandler = (value: string) => {
@ -78,9 +121,11 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
...state,
languageValue: defaultLanguage.value,
hotkeyDefinitions: resolvedHotkeyDefaults,
crosshairModifier: initialCrosshairModifier,
}));
hotkeysManager.restoreDefaultBindings();
localStorage.removeItem(toolBindingsStorageKey);
};
const displayNames = React.useMemo(() => {
@ -165,6 +210,44 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
/>
))}
</UserPreferencesModal.HotkeysGrid>
{state.crosshairModifier != null && (
<>
<UserPreferencesModal.SubHeading>
{t('ModifierKeys', { defaultValue: 'Modifier Keys' })}
</UserPreferencesModal.SubHeading>
<UserPreferencesModal.HotkeysGrid>
<div className="flex items-center justify-between gap-2">
<span className="text-foreground text-base">
{t('CrosshairsModifier', { defaultValue: 'Crosshairs' })}
</span>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground text-sm">
{t('PlusLeftClick', { defaultValue: 'Left Click +' })}
</span>
<Select
value={state.crosshairModifier}
onValueChange={val => setState(s => ({ ...s, crosshairModifier: val }))}
>
<SelectTrigger className="w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODIFIER_OPTIONS.map(opt => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</UserPreferencesModal.HotkeysGrid>
</>
)}
</UserPreferencesModal.Body>
<FooterAction>
<FooterAction.Left>
@ -191,6 +274,19 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
return; // Exit early since we're reloading
}
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
if (toolGroupService && state.crosshairModifier != null) {
const bindings = [
{ mouseButton: 1, modifierKey: Number(state.crosshairModifier) },
];
toolGroupService.setToolBindings('mpr', 'Crosshairs', bindings);
toolGroupService.applyToolBindings('mpr', 'Crosshairs');
localStorage.setItem(
toolBindingsStorageKey,
JSON.stringify({ mpr: { Crosshairs: bindings } })
);
}
hotkeysModule.stopRecord();
hotkeysModule.unpause();
hide();
@ -206,4 +302,5 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
export default {
'ohif.userPreferencesModal': UserPreferencesModalDefault,
'ohif.userPreferences.toolBindingsStorageKey': DEFAULT_TOOL_BINDINGS_STORAGE_KEY,
};

View File

@ -239,6 +239,9 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) {
disabled: [
{
toolName: toolNames.Crosshairs,
bindings: [
{ mouseButton: Enums.MouseBindings.Primary, modifierKey: Enums.KeyboardBindings.Shift },
],
configuration: {
viewportIndicators: true,
viewportIndicatorsConfig: {
@ -246,7 +249,6 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) {
xOffset: 0.95,
yOffset: 0.05,
},
disableOnPassive: true,
autoPan: {
enabled: false,
panSize: 10,

View File

@ -647,15 +647,18 @@ const toolbarButtons: Button[] = [
type: 'tool',
icon: 'tool-crosshair',
label: i18n.t('Buttons:Crosshairs'),
tooltip: i18n.t('Buttons:Click to toggle on or off'),
commands: {
commandName: 'setToolActiveToolbar',
commandName: 'toggleActiveDisabledToolbar',
commandOptions: {
toolGroupIds: ['mpr'],
},
},
evaluate: {
name: 'evaluate.cornerstoneTool',
name: 'evaluate.cornerstoneTool.toggleWithModifier',
disabledText: i18n.t('Buttons:Select an MPR viewport to enable this tool'),
toggledOnIcon: 'tool-crosshair-checked',
defaultIcon: 'tool-crosshair',
},
},
},

View File

@ -133,6 +133,9 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) {
disabled: [
{
toolName: toolNames.Crosshairs,
bindings: [
{ mouseButton: Enums.MouseBindings.Primary, modifierKey: Enums.KeyboardBindings.Shift },
],
configuration: {
disableOnPassive: true,
autoPan: {

View File

@ -99,6 +99,7 @@ import {
ToolCobbAngle,
ToolCreateThreshold,
ToolCrosshair,
ToolCrosshairChecked,
ToolDicomTagBrowser,
ToolFlipHorizontal,
ToolFreehandPolygon,
@ -436,6 +437,7 @@ export const Icons = {
ToolCobbAngle,
ToolCreateThreshold,
ToolCrosshair,
ToolCrosshairChecked,
ToolDicomTagBrowser,
ToolFlipHorizontal,
ToolFreehandPolygon,
@ -703,6 +705,7 @@ export const Icons = {
'tool-cobb-angle': (props: IconProps) => ToolCobbAngle(props),
'tool-create-threshold': (props: IconProps) => ToolCreateThreshold(props),
'tool-crosshair': (props: IconProps) => ToolCrosshair(props),
'tool-crosshair-checked': (props: IconProps) => ToolCrosshairChecked(props),
'dicom-tag-browser': (props: IconProps) => ToolDicomTagBrowser(props),
'tool-flip-horizontal': (props: IconProps) => ToolFlipHorizontal(props),
'tool-freehand-polygon': (props: IconProps) => ToolFreehandPolygon(props),

View File

@ -930,6 +930,68 @@ export const ToolCrosshair = (props: IconProps) => (
</svg>
);
export const ToolCrosshairChecked = (props: IconProps) => (
<svg
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_tool_crosshair_checked)">
<path
d="M14 3V9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 25V19"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M25 14H19"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.001 16.001C15.1056 16.001 16.001 15.1056 16.001 14.001C16.001 12.8964 15.1056 12.001 14.001 12.001C12.8964 12.001 12.001 12.8964 12.001 14.001C12.001 15.1056 12.8964 16.001 14.001 16.001Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M22 17C24.7614 17 27 19.2386 27 22C27 24.7614 24.7614 27 22 27C19.2386 27 17 24.7614 17 22C17 19.2386 19.2386 17 22 17ZM24.7998 19.7998C24.5789 19.6344 24.2652 19.6796 24.0996 19.9004L21.4453 23.4385L19.8535 21.8467C19.6583 21.6514 19.3417 21.6514 19.1465 21.8467C18.9513 22.0419 18.9513 22.3585 19.1465 22.5537L21.1465 24.5537C21.2489 24.6561 21.3907 24.7094 21.5352 24.6992C21.6797 24.6889 21.8134 24.6159 21.9004 24.5L24.9004 20.5C25.0658 20.2791 25.0205 19.9654 24.7998 19.7998Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_tool_crosshair_checked">
<rect
width="28"
height="28"
fill="white"
/>
</clipPath>
</defs>
</svg>
);
export const ToolDicomTagBrowser = (props: IconProps) => (
<svg
width="28px"

View File

@ -8,6 +8,7 @@ import { useIconPresentation } from '../../contextProviders/IconPresentationProv
const baseClasses = '!rounded-lg inline-flex items-center justify-center';
const defaultClasses = 'bg-transparent text-foreground/80 hover:bg-background hover:text-highlight';
const activeClasses = 'bg-highlight text-background hover:!bg-highlight/80';
const toggledClasses = 'bg-transparent text-highlight hover:bg-muted';
const disabledClasses =
'text-foreground hover:bg-muted hover:text-highlight opacity-40 cursor-not-allowed';
@ -33,6 +34,7 @@ interface ToolButtonProps {
tooltip?: string;
size?: 'default' | 'small';
isActive?: boolean;
isToggled?: boolean;
disabled?: boolean;
disabledText?: string;
commands?: Record<string, unknown>;
@ -50,6 +52,7 @@ function ToolButton(props: ToolButtonProps) {
size = 'default',
disabled = false,
isActive = false,
isToggled = false,
disabledText,
commands,
onInteraction,
@ -63,7 +66,7 @@ function ToolButton(props: ToolButtonProps) {
const buttonClasses = cn(
baseClasses,
buttonSizeClass,
disabled ? disabledClasses : isActive ? activeClasses : defaultClasses,
disabled ? disabledClasses : isActive ? activeClasses : isToggled ? toggledClasses : defaultClasses,
className
);
@ -85,6 +88,7 @@ function ToolButton(props: ToolButtonProps) {
data-cy={id}
data-tool={id}
data-active={isActive}
data-toggled={isToggled}
>
<Button
className={buttonClasses}