From 979d619f7ccb426beb75d59721b01c98175d5241 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com> Date: Tue, 5 May 2026 15:22:43 -0400 Subject: [PATCH] fix(ToolGroupService): Centralize tool binding persistence and provide API to add and remove persisted bindings. (#5989) * Reset crosshair modifier to default when resetting user preferences. * Apply bindings when loading persisted bindings. --- .../ToolGroupService/ToolGroupService.ts | 136 ++++++++++++++++-- .../userPreferencesCustomization.tsx | 67 ++++++--- .../services/data/ToolGroupService.md | 8 ++ 3 files changed, 174 insertions(+), 37 deletions(-) diff --git a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts index 7cb17cd21..d1f07c535 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -23,6 +23,12 @@ type Tools = { disabled?: Tool[]; }; +type ToolBindings = Array>; +type PersistedToolBindings = Record>; +type ApplyToolBindingsOptions = { + replaceExisting?: boolean; +}; + export default class ToolGroupService { public static REGISTRATION = { name: 'toolGroupService', @@ -39,6 +45,7 @@ export default class ToolGroupService { customizationService: any; private toolGroupIds: Set = new Set(); private toolBindingsMap: Map>>> = new Map(); + private defaultToolBindingsMap: Map> = new Map(); /** * Service-specific */ @@ -131,6 +138,7 @@ export default class ToolGroupService { ToolGroupManager.destroy(); this.toolGroupIds = new Set(); this.toolBindingsMap.clear(); + this.defaultToolBindingsMap.clear(); eventTarget.removeEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated); } @@ -255,22 +263,61 @@ export default class ToolGroupService { public getToolBindings( toolGroupId: string, toolName: string - ): Array> | undefined { + ): ToolBindings | undefined { return this.toolBindingsMap.get(toolGroupId)?.get(toolName); } - public setToolBindings( - toolGroupId: string, - toolName: string, - bindings: Array> - ): void { + public setToolBindings(toolGroupId: string, toolName: string, bindings: ToolBindings): void { if (!this.toolBindingsMap.has(toolGroupId)) { this.toolBindingsMap.set(toolGroupId, new Map()); } - this.toolBindingsMap.get(toolGroupId).set(toolName, bindings); + this.toolBindingsMap.get(toolGroupId).set(toolName, this._cloneToolBindings(bindings)); } - public applyToolBindings(toolGroupId: string, toolName: string): void { + public getDefaultToolBindings(toolGroupId: string, toolName: string): ToolBindings | undefined { + const defaultBindings = this.defaultToolBindingsMap.get(toolGroupId)?.get(toolName); + return defaultBindings ? this._cloneToolBindings(defaultBindings) : undefined; + } + + public persistToolBindings(toolGroupId: string, toolName: string, bindings: ToolBindings): void { + const persistedBindings = this._readPersistedToolBindings(); + if (!persistedBindings[toolGroupId]) { + persistedBindings[toolGroupId] = {}; + } + + persistedBindings[toolGroupId][toolName] = bindings; + this._writePersistedToolBindings(persistedBindings); + } + + public removePersistedToolBindings(toolGroupId: string, toolName?: string): void { + const persistedBindings = this._readPersistedToolBindings(); + if (!persistedBindings[toolGroupId]) { + return; + } + + if (toolName) { + delete persistedBindings[toolGroupId][toolName]; + if (!Object.keys(persistedBindings[toolGroupId]).length) { + delete persistedBindings[toolGroupId]; + } + } else { + delete persistedBindings[toolGroupId]; + } + + this._writePersistedToolBindings(persistedBindings); + } + + /** + * Applies the currently tracked bindings to the runtime tool instance. + * + * Note: this method may activate tools that are currently Passive or Enabled. + * Assigning bindings is treated as making the tool interactable. + */ + public applyToolBindings( + toolGroupId: string, + toolName: string, + options: ApplyToolBindingsOptions = {} + ): void { const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); if (!toolGroup || !toolGroup.hasTool(toolName)) { return; @@ -285,6 +332,10 @@ export default class ToolGroupService { mode === Enums.ToolModes.Passive || mode === Enums.ToolModes.Enabled ) { + if (options.replaceExisting) { + // Opt-in behavior for callers that need replacement semantics. + toolGroup.setToolDisabled(toolName); + } toolGroup.setToolActive(toolName, { bindings }); } } @@ -310,6 +361,7 @@ export default class ToolGroupService { active.forEach(({ toolName, bindings }) => { if (bindings) { this.setToolBindings(toolGroup.id, toolName, bindings); + this._setDefaultToolBindingsIfMissing(toolGroup.id, toolName, bindings); } toolGroup.setToolActive(toolName, { bindings }); }); @@ -319,6 +371,7 @@ export default class ToolGroupService { passive.forEach(({ toolName, bindings }) => { if (bindings) { this.setToolBindings(toolGroup.id, toolName, bindings); + this._setDefaultToolBindingsIfMissing(toolGroup.id, toolName, bindings); } toolGroup.setToolPassive(toolName); }); @@ -328,6 +381,7 @@ export default class ToolGroupService { enabled.forEach(({ toolName, bindings }) => { if (bindings) { this.setToolBindings(toolGroup.id, toolName, bindings); + this._setDefaultToolBindingsIfMissing(toolGroup.id, toolName, bindings); } toolGroup.setToolEnabled(toolName); }); @@ -337,12 +391,28 @@ export default class ToolGroupService { disabled.forEach(({ toolName, bindings }) => { if (bindings) { this.setToolBindings(toolGroup.id, toolName, bindings); + this._setDefaultToolBindingsIfMissing(toolGroup.id, toolName, bindings); } toolGroup.setToolDisabled(toolName); }); } } + private _setDefaultToolBindingsIfMissing( + toolGroupId: string, + toolName: string, + bindings: ToolBindings + ): void { + if (!this.defaultToolBindingsMap.has(toolGroupId)) { + this.defaultToolBindingsMap.set(toolGroupId, new Map()); + } + + const toolMap = this.defaultToolBindingsMap.get(toolGroupId); + if (!toolMap.has(toolName)) { + toolMap.set(toolName, this._cloneToolBindings(bindings)); + } + } + private _addTools(toolGroup, tools) { const addTools = tools => { tools.forEach(({ toolName, parentTool, configuration }) => { @@ -374,24 +444,60 @@ export default class ToolGroupService { } private _loadPersistedBindings(toolGroupId: string): void { + const toolGroupBindings = this._readPersistedToolBindings()[toolGroupId]; + if (!toolGroupBindings) { + return; + } + + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + for (const [toolName, bindings] of Object.entries(toolGroupBindings)) { + this.setToolBindings(toolGroupId, toolName, bindings as ToolBindings); + + if (!toolGroup || !toolGroup.hasTool(toolName)) { + continue; + } + + const { mode } = toolGroup.getToolOptions(toolName); + if (mode === Enums.ToolModes.Active) { + this.applyToolBindings(toolGroupId, toolName, { replaceExisting: true }); + } + } + } + + private _readPersistedToolBindings(): PersistedToolBindings { try { const stored = localStorage.getItem(this._getToolBindingsStorageKey()); if (!stored) { - return; + 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>); + if (!parsed || typeof parsed !== 'object') { + return {}; } + + return parsed as PersistedToolBindings; } catch { // ignore corrupt localStorage + return {}; } } + private _writePersistedToolBindings(bindings: PersistedToolBindings): void { + const storageKey = this._getToolBindingsStorageKey(); + if (!Object.keys(bindings).length) { + localStorage.removeItem(storageKey); + return; + } + + localStorage.setItem(storageKey, JSON.stringify(bindings)); + } + + private _cloneToolBindings(bindings: ToolBindings): ToolBindings { + return bindings.map(binding => ({ ...binding })); + } + private _getToolBindingsStorageKey(): string { const customizationValue = this.customizationService?.getCustomization( 'ohif.userPreferences.toolBindingsStorageKey' diff --git a/extensions/default/src/customizations/userPreferencesCustomization.tsx b/extensions/default/src/customizations/userPreferencesCustomization.tsx index 6ae8ac2b9..f139ce2cb 100644 --- a/extensions/default/src/customizations/userPreferencesCustomization.tsx +++ b/extensions/default/src/customizations/userPreferencesCustomization.tsx @@ -26,17 +26,12 @@ const MODIFIER_OPTIONS = [ 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 { +function getToolModifier( + toolGroupService: any, + toolGroupId: string, + toolName: string, + mouseButton: number +): string | null { if (!toolGroupService) { return null; } @@ -45,7 +40,28 @@ function getToolModifier(toolGroupService: any, toolGroupId: string, toolName: s return null; } const modifierBinding = bindings.find( - binding => binding.modifierKey != null && binding.numTouchPoints == null + binding => + binding.mouseButton === mouseButton && + binding.modifierKey != null && + binding.numTouchPoints == null + ); + + return modifierBinding?.modifierKey != null ? String(modifierBinding.modifierKey) : null; +} + +function getModifierFromBindings( + bindings: Array> | undefined, + mouseButton: number +): string | null { + if (!bindings?.length) { + return null; + } + + const modifierBinding = bindings.find( + binding => + binding.mouseButton === mouseButton && + binding.modifierKey != null && + binding.numTouchPoints == null ); return modifierBinding?.modifierKey != null ? String(modifierBinding.modifierKey) : null; @@ -55,8 +71,6 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) { 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; @@ -89,7 +103,11 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) { const currentLanguage = currentLanguageFn(); const initialCrosshairModifier = useMemo( - () => getToolModifier(toolGroupService, 'mpr', 'Crosshairs'), + () => getToolModifier(toolGroupService, 'mpr', 'Crosshairs', 1), + [toolGroupService] + ); + const defaultCrosshairBindings = useMemo( + () => toolGroupService?.getDefaultToolBindings?.('mpr', 'Crosshairs'), [toolGroupService] ); @@ -121,11 +139,17 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) { ...state, languageValue: defaultLanguage.value, hotkeyDefinitions: resolvedHotkeyDefaults, - crosshairModifier: initialCrosshairModifier, + crosshairModifier: getModifierFromBindings(defaultCrosshairBindings, 1), })); hotkeysManager.restoreDefaultBindings(); - localStorage.removeItem(toolBindingsStorageKey); + if (toolGroupService && defaultCrosshairBindings?.length) { + toolGroupService.setToolBindings('mpr', 'Crosshairs', defaultCrosshairBindings); + toolGroupService.applyToolBindings('mpr', 'Crosshairs', { + replaceExisting: true, + }); + } + toolGroupService?.removePersistedToolBindings('mpr', 'Crosshairs'); }; const displayNames = React.useMemo(() => { @@ -280,11 +304,10 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) { { mouseButton: 1, modifierKey: Number(state.crosshairModifier) }, ]; toolGroupService.setToolBindings('mpr', 'Crosshairs', bindings); - toolGroupService.applyToolBindings('mpr', 'Crosshairs'); - localStorage.setItem( - toolBindingsStorageKey, - JSON.stringify({ mpr: { Crosshairs: bindings } }) - ); + toolGroupService.applyToolBindings('mpr', 'Crosshairs', { + replaceExisting: true, + }); + toolGroupService.persistToolBindings('mpr', 'Crosshairs', bindings); } hotkeysModule.stopRecord(); diff --git a/platform/docs/docs/platform/services/data/ToolGroupService.md b/platform/docs/docs/platform/services/data/ToolGroupService.md index 37854e929..5149248eb 100644 --- a/platform/docs/docs/platform/services/data/ToolGroupService.md +++ b/platform/docs/docs/platform/services/data/ToolGroupService.md @@ -41,6 +41,14 @@ The `ToolGroupService` emits the following events: - `createToolGroupAndAddTools(toolGroupId, tools)`: Creates a new tool group and adds the specified tools to it. - `getToolConfiguration(toolGroupId, toolName)`: Retrieves the configuration for the specified tool in the given tool group. - `setToolConfiguration(toolGroupId, toolName, config)`: Sets the configuration for the specified tool in the given tool group. +- `getActivePrimaryMouseButtonTool(toolGroupId?)`: Returns the active primary mouse button tool for the specified tool group, or for the active viewport's tool group when omitted. +- `getToolBindings(toolGroupId, toolName)`: Returns the bindings from the service's internal binding map for the specified tool in the given tool group. +- `setToolBindings(toolGroupId, toolName, bindings)`: Updates the service's internal binding map for the specified tool in the given tool group. This does not change the tool's active runtime mode by itself. +- `getAllToolBindings()`: Returns all bindings currently stored in the service's internal binding map across tool groups. +- `getDefaultToolBindings(toolGroupId, toolName)`: Returns the default bindings captured when the tool group was initialized. +- `applyToolBindings(toolGroupId, toolName, options?)`: Reads the currently tracked bindings for that tool and applies them to the underlying runtime tool instance (via tool activation). This can activate tools that are currently `Passive` or `Enabled`, since assigning bindings is treated as making the tool interactable. Use this after `setToolBindings` when you want runtime behavior to update. Set `options.replaceExisting` to `true` to clear previous active bindings before applying. +- `persistToolBindings(toolGroupId, toolName, bindings)`: Persists tool bindings for a specific tool in a tool group to user preferences storage. +- `removePersistedToolBindings(toolGroupId, toolName?)`: Removes persisted tool bindings for a specific tool, or for the whole tool group when `toolName` is omitted. ## Usage