feat(hotkeys): Migrate hotkeys to customization service and fix issues with overrides (#4777)
This commit is contained in:
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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-40
@@ -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,
|
||||
// },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import hotkeyBindings from './hotkeyBindings';
|
||||
import windowLevelPresets from './windowLevelPresets';
|
||||
export { hotkeyBindings, windowLevelPresets };
|
||||
export default { hotkeyBindings, windowLevelPresets };
|
||||
|
||||
export default {
|
||||
hotkeyBindings,
|
||||
windowLevelPresets,
|
||||
};
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user