fix: Combined Hotkeys for special characters (#1233)

* fix: Combined Hotkeys for special characters

* add record method to hotkey manager

* fix record plugin

* remove unused component

* add record to modal props

* rename record method

* replace handlers to use hotkeyRecord

* fix combined keys

* change expected result count from 18 to 17

* autoformat

* Remove duplicate test, that was testing the wrong things; fix label; update configs

* Revert "Remove duplicate test, that was testing the wrong things; fix label; update configs"

This reverts commit 4292f4fe67351962d61cae623b920dcbd87dd71d.

* Fix the record plugin's registration

* fix exposed record method usage

* adding logging for info level items

* Hotkey definitions don't need to be globally reactive; use localstorage/appconfig as sources of truth; not redux

* Tidy up test

* Remove unused code from UserPreferencesForm

* Log info when we run a command

* fix hotkey preference restore

* use application configured hotkeys if there are no user preferred

* Avoid logging circular ref

* Fix callouts

* Fix small issue with array

* Fix langua issue after refactor and merge

* Refactor on recordCurrentCombo as Rodrigo did before

* Separating components in 2 files

* WIP Refactor to simplify the user preferences and move into each form the save and controll functionalities

* Remove context

* Remove unused import

* Initial work on Field treatment

* Refactor General preferences

* Small refactor removing type from HotkeyField

* small update on style

* Refactor and layout fixed

* Make hotkeys preferences working with old hotkeys row

* Move error handling out of hotkey row/input component

* WIP custom form

* Moving validation function to component

* Exposing hotkeyRecord as it does not depend on HotkeyManager Class

* Making hotkeyField as much detached possible from parent component

* Small refactors

* Refactor on user preferences

* Clean up into the changes

* Small fix to let save working

* Style finish

* move about docs into about folder

* Fix double tap on single keys

* Style refactor

* Remove log

* Fix log issues on unit tests

* Fix unit test breaking on ohif/core index

* Fixing hotkeys unpause unit test issue

* Rename file to adopt lowercase

* Rename file to adopt lowercase

* Fixing callouts

* Big refactor miving some of the components into viewer and creating small components into ohif/ui

* Typo on folder name

* Updating ohif ui docs

* Remove comments

* Fix binding of combo keys

* Fix some cypress tests failures

* Fixing onCancel button

* Fixing e2e tests

* Small style update

* Fixing unit tests failing after fix issue

* Remove some not used code

* Remove left over after debug

* Adding prevent default on hotkeys events

* Fixinf existing hotkeys validator with 3 keys pressed

* Exposing hotkeys as root level on ohif-core

* Clean up

* Exposing all availableLanguages with labels and fixing an issue on language switcher

* Fixing e2e cypress tests

* Preveinting some simple errors

* Treating error once we try to set hotkey definitions

* Adding ui notification on setHotkeys errors

* Implementing a service queue request to hold until functions are implemented

* Making sure toFixed is only called on Numbers

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
Co-authored-by: Gustavo André Lelis <galelis@gmail.com>
This commit is contained in:
authored and GitHub committed 2020-02-12 15:35:04 -05:00
1 parent 9a62c28b3f
commit 2f30e7a821
69 files changed
+1211 -1632

No files matched your search

+21 -25
View File
@@ -53,7 +53,6 @@ import store from './store';
import WhiteLabellingContext from './context/WhiteLabellingContext';
import UserManagerContext from './context/UserManagerContext';
import AppContext from './context/AppContext';
const { setUserPreferences } = reduxOHIF.actions;
/** ~~~~~~~~~~~~~ Application Setup */
const commandsManagerConfig = {
@@ -63,8 +62,8 @@ const commandsManagerConfig = {
/** Managers */
const commandsManager = new CommandsManager(commandsManagerConfig);
const hotkeysManager = new HotkeysManager(commandsManager);
const servicesManager = new ServicesManager();
const hotkeysManager = new HotkeysManager(commandsManager, servicesManager);
let extensionManager;
/** ~~~~~~~~~~~~~ End Application Setup */
@@ -116,7 +115,7 @@ class App extends Component {
const {
servers,
hotkeys,
hotkeys: appConfigHotkeys,
cornerstoneExtensionConfig,
extensions,
oidc,
@@ -139,7 +138,7 @@ class App extends Component {
* Must run after extension commands are registered
* if there is no hotkeys from localStorage set up from config.
*/
_initHotkeys(hotkeys);
_initHotkeys(appConfigHotkeys);
_initServers(servers);
initWebWorkers();
}
@@ -263,30 +262,27 @@ function _initExtensions(extensions, cornerstoneExtensionConfig, appConfig) {
extensionManager.registerExtensions(mergedExtensions);
}
function _initHotkeys(hotkeys) {
const { hotkeyDefinitions = {} } = store.getState().preferences || {};
let updateStore = false;
let hotkeysToUse = hotkeyDefinitions;
/**
*
* @param {Object} appConfigHotkeys - Default hotkeys, as defined by app config
*/
function _initHotkeys(appConfigHotkeys) {
// TODO: Use something more resilient
// TODO: Mozilla has a special library for this
const userPreferredHotkeys = JSON.parse(
localStorage.getItem('hotkey-definitions') || '{}'
);
if (!Object.keys(hotkeyDefinitions).length) {
hotkeysToUse = hotkeys;
updateStore = true;
// TODO: hotkeysManager.isValidDefinitionObject(/* */)
const hasUserPreferences =
userPreferredHotkeys && Object.keys(userPreferredHotkeys).length > 0;
if (hasUserPreferences) {
hotkeysManager.setHotkeys(userPreferredHotkeys);
} else {
hotkeysManager.setHotkeys(appConfigHotkeys);
}
if (hotkeysToUse) {
hotkeysManager.setHotkeys(hotkeysToUse);
/* Set hotkeys default based on app config. */
hotkeysManager.setDefaultHotKeys(hotkeys);
if (updateStore) {
const { hotkeyDefinitions } = hotkeysManager;
const windowLevelData = {};
store.dispatch(
setUserPreferences({ windowLevelData, hotkeyDefinitions })
);
}
}
hotkeysManager.setDefaultHotKeys(appConfigHotkeys);
}
function _initServers(servers) {
@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
import ConnectedUserPreferencesForm from '../../connectedComponents/ConnectedUserPreferencesForm';
import { Dropdown, AboutContent, withModal } from '@ohif/ui';
import { UserPreferences } from './../UserPreferences';
import OHIFLogo from '../OHIFLogo/OHIFLogo.js';
import './Header.css';
@@ -42,7 +44,7 @@ function Header(props) {
},
onClick: () =>
show({
content: ConnectedUserPreferencesForm,
content: UserPreferences,
title: t('User Preferences'),
}),
},
@@ -0,0 +1,72 @@
import React, { useState, useSelector } from 'react';
import PropTypes from 'prop-types';
import i18n from '@ohif/i18n';
import { TabFooter, LanguageSwitcher, useSnackbarContext } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import './GeneralPreferences.styl';
/**
* General Preferences tab
* It renders the General Preferences content
*
* @param {object} props component props
* @param {function} props.onClose
*/
function GeneralPreferences({ onClose }) {
const { t } = useTranslation('UserPreferencesModal');
const snackbar = useSnackbarContext();
const currentLanguage = i18n.language;
const { availableLanguages } = i18n;
const [language, setLanguage] = useState(currentLanguage);
const onResetPreferences = () => {
setLanguage(i18n.defaultLanguage);
};
const onSave = () => {
i18n.changeLanguage(language);
onClose();
snackbar.show({
message: t('SaveMessage'),
type: 'success',
});
};
const hasErrors = false;
return (
<React.Fragment>
<div className="GeneralPreferences">
<div className="language">
<label htmlFor="language-select" className="languageLabel">
Language
</label>
<LanguageSwitcher
language={language}
onLanguageChange={setLanguage}
languages={availableLanguages}
/>
</div>
</div>
<TabFooter
onResetPreferences={onResetPreferences}
onSave={onSave}
onCancel={onClose}
hasErrors={hasErrors}
t={t}
/>
</React.Fragment>
);
}
GeneralPreferences.propTypes = {
onClose: PropTypes.func,
};
export { GeneralPreferences };
@@ -0,0 +1,18 @@
.GeneralPreferences
display: flex;
padding: 20px 0;
.language
display: flex;
width: 50%;
.languageLabel
display: flex;
flex-basis: 0;
flex-grow: 1.5;
justify-content: flex-end;
padding-right: 20px;
.language-select
flex-grow: 1.5;
flex-basis: 0;
@@ -0,0 +1,201 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useSnackbarContext, TabFooter, HotkeyField } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import { hotkeysValidators } from './hotkeysValidators';
import { MODIFIER_KEYS } from './hotkeysConfig';
import { hotkeysManager } from '../../App';
import './HotkeysPreferences.styl';
/**
* Take hotkeyDefenintions and build an initialState to be used into the component state
*
* @param {Object} hotkeyDefinitions
* @returns {Object} initialState
*/
const initialState = hotkeyDefinitions => ({
hotkeys: { ...hotkeyDefinitions },
errors: {},
});
/**
* Take the updated command and keys and validate the changes with all validators
*
* @param {Object} arguments
* @param {string} arguments.commandName command name string to be updated
* @param {array} arguments.pressedKeys new array of keys to be added for the commandName
* @param {array} arguments.hotkeys all hotkeys currently into the app
* @returns {Object} {errorMessage} errorMessage coming from any of the validator or undefined if none
*/
const validateCommandKey = ({ commandName, pressedKeys, hotkeys }) => {
for (const validator of hotkeysValidators) {
const validation = validator({
commandName,
pressedKeys,
hotkeys,
});
if (validation && validation.hasError) {
return validation;
}
}
return {
errorMessage: undefined,
};
};
/**
* Take all hotkeys and split the list into two lists
*
* @param {array} hotkeys list of all hotkeys
* @returns {array} array containing two arrays of keys
*/
const splitHotkeys = hotkeys => {
const splitedHotkeys = [];
const arrayHotkeys = Object.entries(hotkeys);
if (arrayHotkeys.length) {
const halfwayThrough = Math.ceil(arrayHotkeys.length / 2);
splitedHotkeys.push(arrayHotkeys.slice(0, halfwayThrough));
splitedHotkeys.push(
arrayHotkeys.slice(halfwayThrough, arrayHotkeys.length)
);
}
return splitedHotkeys;
};
/**
* HotkeysPreferences tab
* It renders all hotkeys displayed into columns/rows
*
* It stores current state and whenever it changes, component messages parent of new value (through function callback)
* @param {object} props component props
* @param {string} props.onClose
*/
function HotkeysPreferences({ onClose }) {
const { t } = useTranslation('UserPreferencesModal');
const { hotkeyDefaults, hotkeyDefinitions } = hotkeysManager;
const [state, setState] = useState(initialState(hotkeyDefinitions));
const snackbar = useSnackbarContext();
const onResetPreferences = () => {
const defaultHotKeyDefinitions = {};
hotkeyDefaults.map(item => {
const { commandName, ...values } = item;
defaultHotKeyDefinitions[commandName] = { ...values };
});
setState(initialState(defaultHotKeyDefinitions));
};
const onSave = () => {
const { hotkeys } = state;
hotkeysManager.setHotkeys(hotkeys);
localStorage.setItem('hotkey-definitions', JSON.stringify(hotkeys));
onClose();
snackbar.show({
message: t('SaveMessage'),
type: 'success',
});
};
const onHotkeyChanged = (commandName, hotkeyDefinition, keys) => {
const { errorMessage } = validateCommandKey({
commandName,
pressedKeys: keys,
hotkeys: state.hotkeys,
});
setState(prevState => ({
hotkeys: {
...prevState.hotkeys,
[commandName]: { ...hotkeyDefinition, keys },
},
errors: {
...prevState.errors,
[commandName]: errorMessage,
},
}));
};
const hasErrors = Object.keys(state.errors).some(key => !!state.errors[key]);
const hasHotkeys = Object.keys(state.hotkeys).length;
const splitedHotkeys = splitHotkeys(state.hotkeys);
return (
<React.Fragment>
<div className="HotkeysPreferences">
{hasHotkeys ? (
<div className="hotkeyTable">
{splitedHotkeys.map((hotkeys, index) => {
return (
<div className="hotkeyColumn" key={index}>
<div className="hotkeyHeader">
<div className="headerItemText text-right">Function</div>
<div className="headerItemText text-center">Shortcut</div>
</div>
{hotkeys.map(hotkey => {
const commandName = hotkey[0];
const hotkeyDefinition = hotkey[1];
const { keys, label } = hotkeyDefinition;
const errorMessage = state.errors[hotkey[0]];
const handleChange = keys => {
onHotkeyChanged(commandName, hotkeyDefinition, keys);
};
return (
<div key={commandName} className="hotkeyRow">
<div className="hotkeyLabel">{label}</div>
<div
data-key="defaultTool"
className={classnames(
'wrapperHotkeyInput',
errorMessage ? 'stateError' : ''
)}
>
<HotkeyField
keys={keys}
modifier_keys={MODIFIER_KEYS}
handleChange={handleChange}
classNames={'hotkeyInput'}
></HotkeyField>
<span className="errorMessage">{errorMessage}</span>
</div>
</div>
);
})}
</div>
);
})}
</div>
) : (
'Hotkeys definitions is empty'
)}
</div>
<TabFooter
onResetPreferences={onResetPreferences}
onSave={onSave}
onCancel={onClose}
hasErrors={hasErrors}
t={t}
/>
</React.Fragment>
);
}
HotkeysPreferences.propTypes = {
onClose: PropTypes.func,
};
export { HotkeysPreferences };
@@ -0,0 +1,73 @@
.HotkeysPreferences
display: flex
padding: 20px
.errorMessage
color: var(--state-error-text)
font-size: 10px
text-transform: uppercase
.hotkeyTable
display: flex
flex-direction: row
flex-basis: 0
flex-grow: 1.5
.hotkeyColumn
display: flex
flex-direction: column
flex-basis: 0
flex-grow: 1.5
.hotkeyHeader
display: flex
flex-direction: row
margin-bottom: 10px
.headerItemText
flex-basis: 0
flex-grow: 1.5
padding: 5px 15px 5px 0
.hotkeyRow
display: flex
.wrapperHotkeyInput
margin-bottom: 5px
cursor: pointer
display: flex
flex-direction: column
flex-basis: 0
flex-grow: 1.5
.hotkeyInput
font-weight: 400
cursor: pointer
transition: background-color .3s ease,border-color .3s ease
background-color: var(--ui-gray)
color: var(--text-primary-color)
border-color: var(--ui-border-coolor)
border: 0
border-radius: 2px
font-size: 14px
height: 30px
width: 100%
line-height: 16px
padding: 8px 9px 6px
text-align: center
.hotkeyInput:focus
border-color: var(--active-color)
background-color: var(--ui-gray-dark)
box-shadow: 0 0 0 2px var(--active-color) !important
outline: 0
.hotkeyLabel
padding: 5px 15px 5px 0
text-align: right
flex-basis: 0
flex-grow: 1.5
.stateError
.hotkeyInput
background-color: var(--state-error)
@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TabComponents } from '@ohif/ui';
// Tabs
import { HotkeysPreferences } from './HotkeysPreferences';
import { WindowLevelPreferences } from './WindowLevelPreferences';
import { GeneralPreferences } from './GeneralPreferences';
const tabs = [
{
name: 'Hotkeys',
Component: HotkeysPreferences,
customProps: {},
hidden: false,
},
{
name: 'General',
Component: GeneralPreferences,
customProps: {},
hidden: false,
},
{
name: 'Window Level',
Component: WindowLevelPreferences,
customProps: {},
hidden: true,
},
];
function UserPreferences({ hide }) {
const customProps = {
onClose: hide,
};
return <TabComponents tabs={tabs} customProps={customProps} />;
}
UserPreferences.propTypes = {
hide: PropTypes.func,
};
export { UserPreferences };
@@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TabFooter } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
function WindowLevelPreferences({ onClose }) {
const { t } = useTranslation('UserPreferencesModal');
const onResetPreferences = () => {};
const onSave = () => {};
const hasErrors = false;
return (
<React.Fragment>
<div className="">Component content: {name}</div>
<div className="">TDB!</div>
<TabFooter
onResetPreferences={onResetPreferences}
onSave={onSave}
onCancel={onClose}
hasErrors={hasErrors}
t={t}
/>
</React.Fragment>
);
}
WindowLevelPreferences.propTypes = {
onClose: PropTypes.func,
};
export { WindowLevelPreferences };
@@ -0,0 +1,91 @@
const range = (start, end) => {
return new Array(end - start).fill().map((d, i) => i + start);
};
export const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
export const DISALLOWED_COMBINATIONS = {
'': [],
alt: ['space'],
shift: [],
ctrl: [
'f4',
'f5',
'f11',
'w',
'r',
't',
'o',
'p',
'a',
'd',
'f',
'g',
'h',
'j',
'l',
'z',
'x',
'c',
'v',
'b',
'n',
'pagedown',
'pageup',
],
'ctrl+shift': ['q', 'w', 'r', 't', 'p', 'a', 'h', 'v', 'b', 'n'],
};
export const SPECIAL_KEYS = {
8: 'backspace',
9: 'tab',
13: 'return',
16: 'shift',
17: 'ctrl',
18: 'alt',
19: 'pause',
20: 'capslock',
27: 'esc',
32: 'space',
33: 'pageup',
34: 'pagedown',
35: 'end',
36: 'home',
37: 'left',
38: 'up',
39: 'right',
40: 'down',
45: 'insert',
46: 'del',
96: '0',
97: '1',
98: '2',
99: '3',
100: '4',
101: '5',
102: '6',
103: '7',
104: '8',
105: '9',
106: '*',
107: '+',
109: '-',
110: '.',
111: '/',
112: 'f1',
113: 'f2',
114: 'f3',
115: 'f4',
116: 'f5',
117: 'f6',
118: 'f7',
119: 'f8',
120: 'f9',
121: 'f10',
122: 'f11',
123: 'f12',
144: 'numlock',
145: 'scroll',
191: '/',
224: 'meta',
};
@@ -0,0 +1,97 @@
import { MODIFIER_KEYS, DISALLOWED_COMBINATIONS } from './hotkeysConfig';
const formatPressedKeys = pressedKeysArray => pressedKeysArray.join('+');
const findConflictingCommand = (hotkeys, currentCommandName, pressedKeys) => {
let firstConflictingCommand = undefined;
const formatedPressedHotkeys = formatPressedKeys(pressedKeys);
for (const commandName in hotkeys) {
const toolHotkeys = hotkeys[commandName].keys;
const formatedToolHotkeys = formatPressedKeys(toolHotkeys);
if (
formatedPressedHotkeys === formatedToolHotkeys &&
commandName !== currentCommandName
) {
firstConflictingCommand = hotkeys[commandName];
break;
}
}
return firstConflictingCommand;
};
const ERROR_MESSAGES = {
MODIFIER:
"It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut",
EMPTY: "Field can't be empty.",
};
// VALIDATORS
const modifierValidator = ({ pressedKeys }) => {
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
// Check if it has a valid modifier
const isModifier = MODIFIER_KEYS.includes(lastPressedKey);
if (isModifier) {
return {
hasError: true,
errorMessage: ERROR_MESSAGES.MODIFIER,
};
}
};
const emptyValidator = ({ pressedKeys = [] }) => {
if (!pressedKeys.length) {
return {
hasError: true,
errorMessage: ERROR_MESSAGES.EMPTY,
};
}
};
const conflictingValidator = ({ commandName, pressedKeys, hotkeys }) => {
const conflictingCommand = findConflictingCommand(
hotkeys,
commandName,
pressedKeys
);
if (conflictingCommand) {
return {
hasError: true,
errorMessage: `"${conflictingCommand.label}" is already using the "${pressedKeys}" shortcut.`,
};
}
};
const disallowedValidator = ({ pressedKeys = [] }) => {
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
const modifierCommand = formatPressedKeys(
pressedKeys.slice(0, pressedKeys.length - 1)
);
const disallowedCombination = DISALLOWED_COMBINATIONS[modifierCommand];
const hasDisallowedCombinations = disallowedCombination
? disallowedCombination.includes(lastPressedKey)
: false;
if (hasDisallowedCombinations) {
return {
hasError: true,
errorMessage: `"${formatPressedKeys(
pressedKeys
)}" shortcut combination is not allowed`,
};
}
};
const hotkeysValidators = [
emptyValidator,
modifierValidator,
conflictingValidator,
disallowedValidator,
];
export { hotkeysValidators };
@@ -0,0 +1 @@
export { UserPreferences } from './UserPreferences';
@@ -1,58 +0,0 @@
import { connect } from 'react-redux';
import { UserPreferencesForm } from '@ohif/ui';
import OHIF from '@ohif/core';
import i18n from '@ohif/i18n';
import { hotkeysManager } from '../App.js';
const { setUserPreferences } = OHIF.redux.actions;
const mapStateToProps = (state, ownProps) => {
const { defaultLanguage } = i18n;
const { hotkeyDefinitions, windowLevelData = {}, generalPreferences } =
state.preferences || {};
const { hotkeyDefaults } = hotkeysManager;
return {
onClose: ownProps.hide,
windowLevelData,
hotkeyDefinitions,
generalPreferences,
hotkeysManager,
hotkeyDefaults,
defaultLanguage,
};
};
const mapDispatchToProps = (dispatch, ownProps) => {
return {
onSave: ({ windowLevelData, hotkeyDefinitions, generalPreferences }) => {
// TODO improve this strategy on windowLevel implementation
hotkeysManager.setHotkeys(hotkeyDefinitions);
const { language } = generalPreferences;
// set new language
i18n.changeLanguage(language);
if (ownProps.hide) {
ownProps.hide();
}
dispatch(
setUserPreferences({
windowLevelData,
hotkeyDefinitions,
generalPreferences,
})
);
},
};
};
const ConnectedUserPreferencesForm = connect(
mapStateToProps,
mapDispatchToProps
)(UserPreferencesForm);
export default ConnectedUserPreferencesForm;