From 691e26731f4f3b3957fe81fc051b09c972696cf6 Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Thu, 30 Apr 2026 10:13:58 -0400 Subject: [PATCH] feat(ui-next): Adds toggle state for ToolButton and Crosshair example (#5914) Co-authored-by: sedghi --- extensions/cornerstone/src/commandsModule.ts | 78 +++++++++---- .../cornerstone/src/getToolbarModule.tsx | 34 ++++++ .../ToolGroupService/ToolGroupService.ts | 110 +++++++++++++++++- .../userPreferencesCustomization.tsx | 99 +++++++++++++++- modes/basic/src/initToolGroups.ts | 4 +- modes/basic/src/toolbarButtons.ts | 7 +- modes/tmtv/src/initToolGroups.js | 3 + .../ui-next/src/components/Icons/Icons.tsx | 3 + .../src/components/Icons/Sources/Tools.tsx | 62 ++++++++++ .../src/components/ToolButton/ToolButton.tsx | 6 +- 10 files changed, 373 insertions(+), 33 deletions(-) diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index a5b9e9627..c55c52c41 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -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) diff --git a/extensions/cornerstone/src/getToolbarModule.tsx b/extensions/cornerstone/src/getToolbarModule.tsx index 229bb9ce2..5f96730a1 100644 --- a/extensions/cornerstone/src/getToolbarModule.tsx +++ b/extensions/cornerstone/src/getToolbarModule.tsx @@ -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: () => { diff --git a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts index 298f28612..7cb17cd21 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -36,7 +36,9 @@ export default class ToolGroupService { cornerstoneViewportService: any; viewportGridService: any; uiNotificationService: any; + customizationService: any; private toolGroupIds: Set = new Set(); + private toolBindingsMap: Map>>> = 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): Types.IToolGroup { @@ -242,29 +252,92 @@ export default class ToolGroupService { return this.getToolGroup(toolGroupId)?.getActivePrimaryMouseButtonTool(); } + public getToolBindings( + toolGroupId: string, + toolName: string + ): Array> | undefined { + return this.toolBindingsMap.get(toolGroupId)?.get(toolName); + } + + public setToolBindings( + toolGroupId: string, + toolName: string, + bindings: Array> + ): 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>; + }> { + 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>); + } + } 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( diff --git a/extensions/default/src/customizations/userPreferencesCustomization.tsx b/extensions/default/src/customizations/userPreferencesCustomization.tsx index 038a40004..6ae8ac2b9 100644 --- a/extensions/default/src/customizations/userPreferencesCustomization.tsx +++ b/extensions/default/src/customizations/userPreferencesCustomization.tsx @@ -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 }) { /> ))} + + {state.crosshairModifier != null && ( + <> + + {t('ModifierKeys', { defaultValue: 'Modifier Keys' })} + + +
+ + {t('CrosshairsModifier', { defaultValue: 'Crosshairs' })} + +
+ + {t('PlusLeftClick', { defaultValue: 'Left Click +' })} + + +
+
+
+ + )} @@ -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, }; diff --git a/modes/basic/src/initToolGroups.ts b/modes/basic/src/initToolGroups.ts index 6278f6261..d5460eed0 100644 --- a/modes/basic/src/initToolGroups.ts +++ b/modes/basic/src/initToolGroups.ts @@ -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, diff --git a/modes/basic/src/toolbarButtons.ts b/modes/basic/src/toolbarButtons.ts index 005674e58..ebddc9435 100644 --- a/modes/basic/src/toolbarButtons.ts +++ b/modes/basic/src/toolbarButtons.ts @@ -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', }, }, }, diff --git a/modes/tmtv/src/initToolGroups.js b/modes/tmtv/src/initToolGroups.js index f844839c3..f88b1cf84 100644 --- a/modes/tmtv/src/initToolGroups.js +++ b/modes/tmtv/src/initToolGroups.js @@ -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: { diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx index 59d787555..5229655bf 100644 --- a/platform/ui-next/src/components/Icons/Icons.tsx +++ b/platform/ui-next/src/components/Icons/Icons.tsx @@ -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), diff --git a/platform/ui-next/src/components/Icons/Sources/Tools.tsx b/platform/ui-next/src/components/Icons/Sources/Tools.tsx index b06dfb363..a42576793 100644 --- a/platform/ui-next/src/components/Icons/Sources/Tools.tsx +++ b/platform/ui-next/src/components/Icons/Sources/Tools.tsx @@ -930,6 +930,68 @@ export const ToolCrosshair = (props: IconProps) => ( ); +export const ToolCrosshairChecked = (props: IconProps) => ( + + + + + + + + + + + + + + + +); + export const ToolDicomTagBrowser = (props: IconProps) => ( ; @@ -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} >