feat(hotkeys): Migrate hotkeys to customization service and fix issues with overrides (#4777)

This commit is contained in:
Alireza authored and GitHub committed 2025-02-25 13:32:30 -05:00
1 parent 4559d39a91
commit 3e6913b097
39 files changed
+814 -1552

No files matched your search

@@ -30,14 +30,6 @@ describe('HotkeysManager', () => {
expect(containsAllExpectedProperties).toBe(true);
});
it('throws Error if instantiated without a commandsManager', () => {
expect(() => {
new HotkeysManager();
}).toThrow(
'HotkeysManager instantiated without a commandsManager. Hotkeys will be unable to find and run commands.'
);
});
describe('disable()', () => {
beforeEach(() => hotkeys.pause.mockClear());
+106 -87
View File
@@ -1,7 +1,7 @@
import objectHash from 'object-hash';
import isEqual from 'lodash.isequal';
import { hotkeys } from '../utils';
import { hotkeys as mouseTrapAPI } from '../utils';
import Hotkey from './Hotkey';
import migrateOldHotkeyDefinitions from '../utils/hotkeys/migrateHotkeys';
/**
*
@@ -12,23 +12,24 @@ import Hotkey from './Hotkey';
* @property {String} label - Display name for hotkey
* @property {String[]} keys - Keys to bind; Follows Mousetrap.js binding syntax
*/
export class HotkeysManager {
private _servicesManager: AppTypes.ServicesManager;
private _commandsManager: AppTypes.CommandsManager;
private isEnabled: boolean = true;
public hotkeyDefinitions: Record<string, any> = {};
public hotkeyDefaults: any[] = [];
constructor(commandsManager, servicesManager: AppTypes.ServicesManager) {
this.hotkeyDefinitions = {};
this.hotkeyDefaults = [];
this.isEnabled = true;
if (!commandsManager) {
throw new Error(
'HotkeysManager instantiated without a commandsManager. Hotkeys will be unable to find and run commands.'
);
}
constructor(
commandsManager: AppTypes.CommandsManager,
servicesManager: AppTypes.ServicesManager
) {
this._servicesManager = servicesManager;
this._commandsManager = commandsManager;
// Check for old hotkey definitions format and migrate if needed
migrateOldHotkeyDefinitions({
generateHash: this.generateHash,
});
}
/**
@@ -37,7 +38,12 @@ export class HotkeysManager {
* @param {*} event
*/
record(event) {
return hotkeys.record(event);
return mouseTrapAPI.record(event);
}
cancel() {
mouseTrapAPI.stopRecord();
mouseTrapAPI.unpause();
}
/**
@@ -46,7 +52,7 @@ export class HotkeysManager {
*/
disable() {
this.isEnabled = false;
hotkeys.pause();
mouseTrapAPI.pause();
}
/**
@@ -54,22 +60,35 @@ export class HotkeysManager {
*/
enable() {
this.isEnabled = true;
hotkeys.unpause();
mouseTrapAPI.unpause();
}
/**
* Registers a list of hotkeydefinitions.
* Uses most recent
*
* @returns {undefined}
*/
restoreDefaultBindings() {
this.setHotkeys(this.hotkeyDefaults);
}
/**
*
*/
destroy() {
this.hotkeyDefaults = [];
this.hotkeyDefinitions = {};
mouseTrapAPI.reset();
}
/**
* Registers a list of hotkey definitions.
*
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
*/
setHotkeys(hotkeyDefinitions = [], name = 'hotkey-definitions') {
setHotkeys(hotkeyDefinitions = []) {
try {
const definitions = this.getValidDefinitions(hotkeyDefinitions);
if (isEqual(definitions, this.hotkeyDefaults)) {
localStorage.removeItem(name);
} else {
localStorage.setItem(name, JSON.stringify(definitions));
}
definitions.forEach(definition => this.registerHotkeys(definition));
} catch (error) {
const { uiNotificationService } = this._servicesManager.services;
@@ -81,6 +100,13 @@ export class HotkeysManager {
}
}
generateHash(definition) {
return objectHash({
commandName: definition.commandName,
commandOptions: definition.commandOptions || {},
});
}
/**
* Set default hotkey bindings. These
* values are used in `this.restoreDefaultBindings`.
@@ -90,6 +116,25 @@ export class HotkeysManager {
setDefaultHotKeys(hotkeyDefinitions = []) {
const definitions = this.getValidDefinitions(hotkeyDefinitions);
this.hotkeyDefaults = definitions;
// Get user preferred keys from localStorage
const userPreferredKeys = JSON.parse(localStorage.getItem('user-preferred-keys') || '{}');
// Update definitions with user preferred keys before setting
const updatedDefinitions = definitions.map(definition => {
const commandHash = this.generateHash(definition);
// If user has a preferred key binding, use it
if (userPreferredKeys[commandHash]) {
return {
...definition,
keys: userPreferredKeys[commandHash],
};
}
return definition;
});
this.setHotkeys(updatedDefinitions);
}
/**
@@ -103,6 +148,13 @@ export class HotkeysManager {
? [...hotkeyDefinitions]
: this._parseToArrayLike(hotkeyDefinitions);
// make sure isEditable is true for all definitions if not provided
definitions.forEach(definition => {
if (definition.isEditable === undefined) {
definition.isEditable = true;
}
});
return definitions;
}
@@ -142,18 +194,6 @@ export class HotkeysManager {
* Return HotkeyDefinition object like based on given property name and property value
* @param {string} propertyName property name of hotkey definition object
* @param {object} propertyValue property value of hotkey definition object
*
* @example
*
* const hotKeyObj = {hotKeyDefA: {keys:[],....}}
*
* const parsed = _parseToHotKeyObj(Object.keys(hotKeyDefA)[0], hotKeyObj[hotKeyDefA]);
* {
* commandName: hotKeyDefA,
* keys: [],
* ....
* }
*
*/
_parseToHotKeyObj(propertyName, propertyValue) {
return {
@@ -163,65 +203,44 @@ export class HotkeysManager {
}
/**
* (unbinds and) binds the specified command to one or more key combinations.
* When a hotkey combination is triggered, the command name and active contexts
* are used to locate the correct command to call.
* (Unbinds and) binds the specified command to one or more key combinations.
* When the hotkey combination is triggered, the command name and active contexts
* are used to locate and execute the appropriate command.
*
* @param {HotkeyDefinition} command
* @param {String} extension
* @returns {undefined}
* @param hotkey - The hotkey definition object.
* @throws {Error} Throws an error if no commandName is provided.
*/
registerHotkeys(
{ commandName, commandOptions = {}, context, keys, label, isEditable }: Hotkey = {},
extension
) {
registerHotkeys({
commandName,
commandOptions = {},
context,
keys,
label,
isEditable,
}: Hotkey): void {
if (!commandName) {
throw new Error(`No command was defined for hotkey "${keys}"`);
}
const commandHash = objectHash({ commandName, commandOptions });
const options = Object.keys(commandOptions).length ? JSON.stringify(commandOptions) : 'no';
const previouslyRegisteredDefinition = this.hotkeyDefinitions[commandHash];
const commandHash = this.generateHash({ commandName, commandOptions });
const existingHotkey = this.hotkeyDefinitions[commandHash];
if (previouslyRegisteredDefinition) {
const previouslyRegisteredKeys = previouslyRegisteredDefinition.keys;
this._unbindHotkeys(commandName, previouslyRegisteredKeys);
// log.info(
// `[hotkeys] Unbinding ${commandName} with ${options} options from ${previouslyRegisteredKeys}`
// );
// If the hotkey has already been registered with the same keys, skip re-registration.
if (existingHotkey && existingHotkey.keys === keys) {
console.debug('HotkeysManager: Identical hotkey registration skipped.');
return;
}
// Set definition & bind
this.hotkeyDefinitions[commandHash] = {
commandName,
commandOptions,
keys,
label,
isEditable,
};
const userPreferredKeys = JSON.parse(localStorage.getItem('user-preferred-keys') || '{}');
if (existingHotkey) {
userPreferredKeys[commandHash] = keys;
localStorage.setItem('user-preferred-keys', JSON.stringify(userPreferredKeys));
this._unbindHotkeys(commandName, existingHotkey.keys);
}
this.hotkeyDefinitions[commandHash] = { commandName, commandOptions, keys, label, isEditable };
this._bindHotkeys(commandName, commandOptions, context, keys);
// log.info(
// `[hotkeys] Binding ${commandName} with ${options} from ${context ||
// 'default'} options to ${keys}`
// );
}
/**
* Uses most recent
*
* @returns {undefined}
*/
restoreDefaultBindings() {
this.setHotkeys(this.hotkeyDefaults);
}
/**
*
*/
destroy() {
this.hotkeyDefaults = [];
this.hotkeyDefinitions = {};
hotkeys.reset();
}
/**
@@ -241,7 +260,7 @@ export class HotkeysManager {
const isKeyArray = keys instanceof Array;
const combinedKeys = isKeyArray ? keys.join('+') : keys;
hotkeys.bind(combinedKeys, evt => {
mouseTrapAPI.bind(combinedKeys, evt => {
evt.preventDefault();
evt.stopPropagation();
this._commandsManager.runCommand(commandName, { evt, ...commandOptions }, context);
@@ -269,7 +288,7 @@ export class HotkeysManager {
return;
}
hotkeys.unbind(keys);
mouseTrapAPI.unbind(keys);
}
}
@@ -1,8 +1,3 @@
import windowLevelPresets from './windowLevelPresets';
/*
* Supported Keys: https://craig.is/killing/mice
*/
const bindings = [
{
commandName: 'setToolActive',
@@ -144,63 +139,46 @@ const bindings = [
keys: ['esc'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[1],
commandName: 'setWindowLevelPreset',
commandOptions: { presetName: 'ct-soft-tissue', presetIndex: 0 },
label: 'W/L Preset 1',
keys: ['1'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[2],
commandName: 'setWindowLevelPreset',
commandOptions: { presetName: 'ct-lung', presetIndex: 1 },
label: 'W/L Preset 2',
keys: ['2'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[3],
commandName: 'setWindowLevelPreset',
commandOptions: { presetName: 'ct-bone', presetIndex: 2 },
label: 'W/L Preset 3',
keys: ['3'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[4],
commandName: 'setWindowLevelPreset',
commandOptions: { presetName: 'ct-brain', presetIndex: 3 },
label: 'W/L Preset 4',
keys: ['4'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[5],
label: 'W/L Preset 5',
keys: ['5'],
},
{
commandName: 'deleteActiveAnnotation',
label: 'Delete Annotation',
keys: ['backspace'],
},
// These don't exist, so don't try applying them....
// after we have the ui for undo/redo, we can add these back in
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[6],
// label: 'W/L Preset 6',
// keys: ['6'],
// commandName: 'undo',
// label: 'Undo',
// keys: ['ctrl+z'],
// isEditable: true,
// },
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[7],
// label: 'W/L Preset 7',
// keys: ['7'],
// },
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[8],
// label: 'W/L Preset 8',
// keys: ['8'],
// },
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[9],
// label: 'W/L Preset 9',
// keys: ['9'],
// commandName: 'redo',
// label: 'Redo',
// keys: ['ctrl+y'],
// isEditable: true,
// },
];
+5 -2
View File
@@ -1,4 +1,7 @@
import hotkeyBindings from './hotkeyBindings';
import windowLevelPresets from './windowLevelPresets';
export { hotkeyBindings, windowLevelPresets };
export default { hotkeyBindings, windowLevelPresets };
export default {
hotkeyBindings,
windowLevelPresets,
};
-6
View File
@@ -147,12 +147,6 @@ declare global {
maxNumPrefetchRequests: number;
order: 'closest' | 'downward' | 'upward';
};
tours?: Array<{
id: string;
steps: StepOptions[];
tourOptions: TourOptions;
route: string;
}>;
}
export interface Test {
@@ -0,0 +1,76 @@
import defaults from '../../defaults';
const defaultHotkeyBindings = defaults.hotkeyBindings;
/**
* Migrates old hotkey definitions from localStorage to the new format
* Old format: 'hotkey-definitions' containing full hotkey definitions array
* New format: 'user-preferred-keys' containing hashed command keys with their key bindings
*
* @private
*/
function migrateOldHotkeyDefinitions({
generateHash,
}: {
generateHash: (definition: Record<string, unknown>) => string;
}): void {
try {
const oldHotkeyDefinitions = localStorage.getItem('hotkey-definitions');
const migrated = localStorage.getItem('hotkeys-migrated');
if (!oldHotkeyDefinitions || migrated === 'true') {
return;
}
const oldDefinitions = JSON.parse(oldHotkeyDefinitions);
if (!Array.isArray(oldDefinitions) || oldDefinitions.length === 0) {
return;
}
const defaultBindings = defaultHotkeyBindings || [];
const userPreferredKeys = JSON.parse(localStorage.getItem('user-preferred-keys') || '{}');
oldDefinitions.forEach(oldDefinition => {
if (!oldDefinition.commandName || !oldDefinition.keys) {
return;
}
const matchingDefault = defaultBindings.find(defaultBinding => {
const sameCommand = defaultBinding.commandName === oldDefinition.commandName;
const oldOptions = oldDefinition.commandOptions || {};
const defaultOptions = defaultBinding.commandOptions || {};
const sameOptions = JSON.stringify(oldOptions) === JSON.stringify(defaultOptions);
return sameCommand && sameOptions;
});
if (
!matchingDefault ||
JSON.stringify(matchingDefault.keys) !== JSON.stringify(oldDefinition.keys)
) {
const commandHash = generateHash({
commandName: oldDefinition.commandName,
commandOptions: oldDefinition.commandOptions || {},
});
userPreferredKeys[commandHash] = oldDefinition.keys;
console.debug(`HotkeysManager: Migrated custom hotkey for ${oldDefinition.commandName}`);
}
});
localStorage.setItem('user-preferred-keys', JSON.stringify(userPreferredKeys));
localStorage.setItem('hotkeys-migrated', 'true');
localStorage.removeItem('hotkey-definitions');
console.debug('HotkeysManager: Successfully migrated hotkey definitions to new format');
} catch (error) {
console.error('HotkeysManager: Error migrating hotkey definitions', error);
localStorage.setItem('hotkeys-migrated', 'false');
}
}
export default migrateOldHotkeyDefinitions;