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.
This commit is contained in:
Joe Boccanfuso 2026-05-05 15:22:43 -04:00 committed by GitHub
parent 5bccf0a753
commit 979d619f7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 174 additions and 37 deletions

View File

@ -23,6 +23,12 @@ type Tools = {
disabled?: Tool[];
};
type ToolBindings = Array<Record<string, unknown>>;
type PersistedToolBindings = Record<string, Record<string, ToolBindings>>;
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<string> = new Set();
private toolBindingsMap: Map<string, Map<string, Array<Record<string, unknown>>>> = new Map();
private defaultToolBindingsMap: Map<string, Map<string, ToolBindings>> = 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<Record<string, unknown>> | undefined {
): ToolBindings | undefined {
return this.toolBindingsMap.get(toolGroupId)?.get(toolName);
}
public setToolBindings(
toolGroupId: string,
toolName: string,
bindings: Array<Record<string, unknown>>
): 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<Record<string, unknown>>);
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'

View File

@ -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<Record<string, unknown>> | 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();

View File

@ -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