OHIF-332: Users should be able to see all available hotkeys and language settings in one place (#1895)

* OHIF-330: Update Modal Styles

* feat/ohif-332: finish raw ui

* feat/ohif-332: update mode configuration strategy

* feat/ohif-332: fix hotkey errors

* feat/ohif-332: update hotkey logic with recent merged changes

* feat/ohif-322: wrap

* feat/ohif-322: add disable state

* ohif-332: cr updates

* ohif-332: disable

* ohif-332: cr updates

* ohif-332: extract header component

* ohif-332: cr update to fix merge conflicts and design issue

Co-authored-by: Rodrigo Antinarelli <rodrigoantinarelli@gmail.com>
This commit is contained in:
Igor OctavianoandRodrigo Antinarelli authored and GitHub committed 2020-08-19 15:02:59 -04:00
1 parent ae3d05ebf7
commit 3e944780dc
37 files changed
+913 -256

No files matched your search

+5
View File
@@ -30,6 +30,11 @@ export {
/** COMPONENTS */
export {
AboutModal,
HotkeyField,
Header,
UserPreferences,
HotkeysPreferences,
Button,
ButtonGroup,
ContextMenu,
@@ -0,0 +1,101 @@
import React from 'react';
import { Typography, Icon } from '../../components';
import detect from 'browser-detect';
const Link = ({ href, children, showIcon = false }) => {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
<Typography
variant="subtitle"
component="p"
className="flex items-center text-primary-active"
>
{children}
{!!showIcon && (
<Icon name="external-link" className="w-5 text-white ml-2" />
)}
</Typography>
</a>
);
};
const Row = ({ title, value, link }) => {
return (
<div className="flex mb-4">
<Typography variant="subtitle" component="p" className="text-white w-48">
{title}
</Typography>
{link ? (
<Link href={link}>{value}</Link>
) : (
<Typography
variant="subtitle"
component="p"
className="text-white w-48"
>
{value}
</Typography>
)}
</div>
);
};
const AboutModal = () => {
const { os, version, name } = detect();
const browser = `${name[0].toUpperCase()}${name.substr(1)} ${version}`;
const renderRowTitle = title => (
<div className="border-b-2 border-black pb-3 mb-3">
<Typography variant="h6" className="text-primary-light">
{title}
</Typography>
</div>
);
return (
<div>
{renderRowTitle('Important Links')}
<div className="flex mb-8">
<Link
href="https://groups.google.com/forum/#!forum/cornerstone-platform"
showIcon={true}
>
Visit the forum
</Link>
<span className="ml-4">
<Link
href="https://github.com/OHIF/Viewers/issues/new/choose"
showIcon={true}
>
Report an issue
</Link>
</span>
<span className="ml-4">
<Link href="http://ohif.org/" showIcon={true}>
More details
</Link>
</span>
</div>
{renderRowTitle('Version Information')}
<div className="flex flex-col">
<Row
title="Repository URL"
value="https://github.com/OHIF/Viewers/"
link="https://github.com/OHIF/Viewers/"
/>
<Row
title="Last Master Commits"
value="https://github.com/OHIF/Viewers/commits/master"
link="https://github.com/OHIF/Viewers/commits/master"
/>
<Row title="Version Number" value={process.env.VERSION_NUMBER} />
<Row title="Build number" value={process.env.BUILD_NUM} />
<Row title="Browser" value={browser} />
<Row title="OS" value={os} />
</div>
</div>
);
};
export default AboutModal;
@@ -0,0 +1,30 @@
---
name: AboutModal
menu: General
route: components/aboutModal
---
import { Playground, Props } from 'docz';
import { AboutModal } from '@ohif/ui';
# AboutModal
AboutModal are used to show application version information.
## Import
```javascript
import { AboutModal } from '@ohif/ui';
```
## Usage
<Playground>
<div className="p-4">
<AboutModal />
</div>
</Playground>
## Properties
<Props of={AboutModal} />
@@ -0,0 +1,2 @@
import AboutModal from './AboutModal';
export default AboutModal;
@@ -64,6 +64,8 @@ const variantClasses = {
'bg-white text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
black:
'bg-black text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
light:
'border bg-primary-light border-primary-light text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
},
};
@@ -154,6 +156,7 @@ Button.propTypes = {
'white',
'black',
'inherit',
'light'
]),
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,
@@ -0,0 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
// TODO: This may fail if package is split from PWA build
import { useHistory } from 'react-router-dom';
import { NavBar, Svg, Icon, IconButton, Dropdown } from '@ohif/ui';
function Header({ children, menuOptions, isReturnEnabled }) {
const { t } = useTranslation();
const history = useHistory();
const onReturnHandler = () => {
if (isReturnEnabled) {
history.push('/');
}
};
return (
<NavBar className="justify-between border-b-4 border-black">
<div className="flex justify-between flex-1">
<div className="flex items-center">
{/* // TODO: Should preserve filter/sort
// Either injected service? Or context (like react router's `useLocation`?) */}
<div
className={classNames("inline-flex items-center mr-3", isReturnEnabled && 'cursor-pointer')}
onClick={onReturnHandler}
>
{isReturnEnabled && <Icon name="chevron-left" className="w-8 text-primary-active" />}
<div className="ml-4"><Svg name="logo-ohif" /></div>
</div>
</div>
<div className="flex items-center">{children}</div>
<div className="flex items-center">
<span className="mr-3 text-lg text-common-light">
{t('Header:INVESTIGATIONAL USE ONLY')}
</span>
<Dropdown showDropdownIcon={false} list={menuOptions}>
<IconButton
variant="text"
color="inherit"
size="initial"
className="text-primary-active"
>
<Icon name="settings" />
</IconButton>
<IconButton
variant="text"
color="inherit"
size="initial"
className="text-primary-active"
>
<Icon name="chevron-down" />
</IconButton>
</Dropdown>
</div>
</div>
</NavBar>
);
}
Header.propTypes = {
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
isReturnEnabled: PropTypes.bool
};
Header.defaultProps = {
isReturnEnabled: true
};
export default Header;
@@ -0,0 +1,2 @@
import Header from './Header';
export default Header;
@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import { hotkeys } from '@ohif/core';
import { Input } from '@ohif/ui';
import { getKeys, formatKeysForInput } from './utils';
/**
* HotkeyField
* Renders a hotkey input that records keys
*
* @param {object} props component props
* @param {Array[]} props.keys keys to be controlled by this field
* @param {boolean} props.disabled disables the field
* @param {function} props.onChange callback with changed values
* @param {string} props.className input classes
* @param {Array[]} props.modifierKeys
*/
const HotkeyField = ({ disabled, keys, onChange, className, modifierKeys }) => {
const inputValue = formatKeysForInput(keys);
const onInputKeyDown = event => {
event.stopPropagation();
event.preventDefault();
hotkeys.record(sequence => {
const keys = getKeys({ sequence, modifierKeys });
hotkeys.unpause();
onChange(keys);
});
};
const onFocus = () => {
hotkeys.pause();
hotkeys.startRecording();
};
return (
<Input
readOnly
disabled={disabled}
value={inputValue}
onKeyDown={onInputKeyDown}
onFocus={onFocus}
className={className}
/>
);
};
HotkeyField.propTypes = {
keys: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
className: PropTypes.string,
modifierKeys: PropTypes.array,
disabled: PropTypes.bool,
};
HotkeyField.defaultProps = {
disabled: false
};
export default HotkeyField;
@@ -0,0 +1,3 @@
import HotkeyField from './HotkeyField.jsx';
export default HotkeyField;
@@ -0,0 +1,31 @@
/**
* Take the pressed key array and return the readable string for the keys
*
* @param {Array} [keys=[]]
* @returns {string} string representation of an array of keys
*/
const formatKeysForInput = (keys = []) => keys.join('+');
/**
* formats given keys sequence to insert the modifier keys in the first index of the array
* @param {string} sequence keys sequence from MouseTrap Record -> "shift+left"
* @returns {Array} keys in array-format -> ['shift','left']
*/
const getKeys = ({ sequence, modifierKeys }) => {
const keysArray = sequence.join(' ').split('+');
let keys = [];
let modifiers = [];
keysArray.forEach(key => {
if (modifierKeys && modifierKeys.includes(key)) {
modifiers.push(key);
} else {
keys.push(key);
}
});
return [...modifiers, ...keys];
};
export {
getKeys,
formatKeysForInput
};
@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { HotkeyField, Typography } from '@ohif/ui';
/* TODO: Move these configs and utils to core? */
import { MODIFIER_KEYS } from './hotkeysConfig';
import { validate, splitHotkeyDefinitionsAndCreateTuples } from './utils';
const HotkeysPreferences = ({ disabled, hotkeyDefinitions, errors: controlledErrors, onChange }) => {
const visibleHotkeys = Object.keys(hotkeyDefinitions)
.filter(key => hotkeyDefinitions[key].isEditable)
.reduce((obj, key) => {
obj[key] = hotkeyDefinitions[key];
return obj;
}, {});
const [errors, setErrors] = useState(controlledErrors);
const splitedHotkeys = splitHotkeyDefinitionsAndCreateTuples(visibleHotkeys);
if (!Object.keys(hotkeyDefinitions).length) {
return 'No hotkeys definitions';
}
const onHotkeyChangeHandler = (id, definition) => {
const { error } = validate({
commandName: id,
pressedKeys: definition.keys,
hotkeys: hotkeyDefinitions,
});
setErrors(prevState => {
const errors = { ...prevState, [id]: error };
onChange(id, definition, errors);
return errors;
});
};
return (
<div className='flex flex-row justify-center'>
<div className='flex flex-row justify-evenly w-full'>
{splitedHotkeys.map((hotkeys, index) => {
return (
<div key={`HotkeyGroup@${index}`} className='flex flex-row'>
<div className='p-2 text-right flex flex-col'>
{hotkeys.map((hotkey, hotkeyIndex) => {
const [id, definition] = hotkey;
const isFirst = hotkeyIndex === 0;
const error = errors[id];
const onChangeHandler = keys => onHotkeyChangeHandler(id, { ...definition, keys });
return (
<div key={`HotkeyItem@${hotkeyIndex}`} className='flex flex-row justify-end mb-2'>
<div className='flex flex-col items-center'>
<Typography
variant='subtitle'
className={classNames('pr-6 w-full text-right text-primary-light', !isFirst && 'hidden')}
>
Function
</Typography>
<Typography
variant='subtitle'
className={classNames('pr-6 h-full flex flex-row items-center whitespace-no-wrap', isFirst && 'mt-5')}>
{definition.label}
</Typography>
</div>
<div className='flex flex-col'>
<Typography
variant='subtitle'
className={classNames('pr-6 pl-0 text-left text-primary-light', !isFirst && 'hidden')}
>
Shortcut
</Typography>
<div className={classNames('flex flex-col w-32', isFirst && 'mt-5')}>
<HotkeyField
disabled={disabled}
keys={definition.keys}
modifierKeys={MODIFIER_KEYS}
onChange={onChangeHandler}
className='text-lg h-8'
/>
{error && <span className='p-2 text-red-600 text-sm'>{error}</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
};
const noop = () => { };
HotkeysPreferences.propTypes = {
onChange: PropTypes.func,
disabled: PropTypes.bool,
hotkeyDefinitions: PropTypes.object.isRequired,
};
HotkeysPreferences.defaultProps = {
onChange: noop,
disabled: false
};
export default HotkeysPreferences;
@@ -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,87 @@
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 { error: ERROR_MESSAGES.MODIFIER };
}
};
const emptyValidator = ({ pressedKeys = [] }) => {
if (!pressedKeys.length) {
return { error: ERROR_MESSAGES.EMPTY };
}
};
const conflictingValidator = ({ commandName, pressedKeys, hotkeys }) => {
const conflictingCommand = findConflictingCommand(
hotkeys,
commandName,
pressedKeys
);
if (conflictingCommand) {
return {
error: `"${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 {
error: `"${formatPressedKeys(pressedKeys)}" shortcut combination is not allowed`,
};
}
};
const hotkeysValidators = [
emptyValidator,
modifierValidator,
conflictingValidator,
disallowedValidator,
];
export { hotkeysValidators };
@@ -0,0 +1,3 @@
import HotkeysPreferences from './HotkeysPreferences.jsx';
export default HotkeysPreferences;
@@ -0,0 +1,52 @@
import { hotkeysValidators } from './hotkeysValidators';
/**
* Split hotkeys definitions and create hotkey related tuples
*
* @param {array} hotkeyDefinitions
* @returns {array} array of tuples consisted of command name and hotkey definition
*/
const splitHotkeyDefinitionsAndCreateTuples = hotkeyDefinitions => {
const splitedHotkeys = [];
const arrayHotkeys = Object.entries(hotkeyDefinitions);
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;
};
/**
* Validate a hotkey change
*
* @param {Object} arguments
* @param {string} arguments.commandName command name or id
* @param {array} arguments.pressedKeys new keys
* @param {array} arguments.hotkeys current hotkeys
* @returns {Object} {error} validation error
*/
const validate = ({ commandName, pressedKeys, hotkeys }) => {
for (const validator of hotkeysValidators) {
const validation = validator({
commandName,
pressedKeys,
hotkeys,
});
if (validation && validation.error) {
return validation;
}
}
return { error: undefined };
};
export {
validate,
splitHotkeyDefinitionsAndCreateTuples
};
+14 -3
View File
@@ -4,7 +4,7 @@ import Label from '../Label';
import classnames from 'classnames';
const baseInputClasses =
'shadow transition duration-300 appearance-none border border-primary-main hover:border-gray-500 focus:border-gray-500 focus:outline-none rounded w-full py-2 px-3 mt-2 text-sm text-white leading-tight focus:outline-none';
'shadow transition duration-300 appearance-none border border-primary-main hover:border-gray-500 focus:border-gray-500 focus:outline-none rounded w-full py-2 px-3 text-sm text-white leading-tight focus:outline-none';
const transparentClasses = {
true: 'bg-transparent',
@@ -23,6 +23,9 @@ const Input = ({
onFocus,
autoFocus,
onKeyPress,
onKeyDown,
readOnly,
disabled,
...otherProps
}) => {
return (
@@ -30,16 +33,21 @@ const Input = ({
<Label className={labelClassName} text={label}></Label>
<input
className={classnames(
label && 'mt-2',
className,
baseInputClasses,
transparentClasses[transparent]
transparentClasses[transparent],
{ 'cursor-not-allowed': disabled }
)}
autoFocus
disabled={disabled}
readOnly={readOnly}
autoFocus={autoFocus}
type={type}
value={value}
onChange={onChange}
onFocus={onFocus}
onKeyPress={onKeyPress}
onKeyDown={onKeyDown}
{...otherProps}
/>
</div>
@@ -57,7 +65,10 @@ Input.propTypes = {
onChange: PropTypes.func,
onFocus: PropTypes.func,
autoFocus: PropTypes.bool,
readOnly: PropTypes.bool,
onKeyPress: PropTypes.func,
onKeyDown: PropTypes.func,
disabled: PropTypes.bool
};
export default Input;
+3 -3
View File
@@ -74,14 +74,14 @@ const Select = ({
components={_components}
placeholder={placeholder}
options={options}
value={selectedOptions}
value={isMulti ? selectedOptions : value}
onChange={(selectedOptions, { action }) => {
const newSelection = !selectedOptions.length
? selectedOptions
: selectedOptions.reduce((acc, curr) => acc.concat([curr.value]), []);
onChange(newSelection, action);
}}
></ReactSelect>
/>
);
};
@@ -112,7 +112,7 @@ Select.propTypes = {
})
),
placeholder: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.string),
value: PropTypes.oneOfType(PropTypes.string, PropTypes.arrayOf(PropTypes.string)),
};
export default Select;
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Select, Typography, Button, HotkeysPreferences } from '@ohif/ui';
import i18n from '@ohif/i18n';
import { useTranslation } from 'react-i18next';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
const UserPreferences = ({ disabled, hotkeyDefinitions, hotkeyDefaults, onCancel, onSubmit, onReset }) => {
const { t } = useTranslation('UserPreferencesModal');
const [state, setState] = useState({
isDisabled: disabled,
hotkeyErrors: {},
hotkeyDefinitions,
language: currentLanguage()
});
const onSubmitHandler = () => {
i18n.changeLanguage(state.language.value);
onSubmit(state);
};
const onResetHandler = () => {
setState(state => ({
...state,
language: defaultLanguage,
hotkeyDefinitions: hotkeyDefaults,
hotkeyErrors: {},
isDisabled: disabled,
}));
onReset();
};
const onCancelHandler = () => {
setState({ hotkeyDefinitions });
onCancel();
};
const onLanguageChangeHandler = (value) => {
setState(state => ({ ...state, language: value }));
};
const onHotkeysChangeHandler = (id, definition, errors) => {
setState(state => ({
...state,
isDisabled: Object.values(errors).every(e => e !== undefined),
hotkeyErrors: errors,
hotkeyDefinitions: {
...state.hotkeyDefinitions,
[id]: definition,
}
}));
};
const Section = ({ title, children }) => (
<>
<div className="border-b-2 border-black mb-2">
<Typography
variant="h5"
className="flex flex-grow text-primary-light font-light pb-2"
>
{title}
</Typography>
</div>
<div className="mt-4 mb-8">
{children}
</div>
</>
);
return (
<div className="p-2">
<Section title="General">
<div className="flex flex-row justify-center items-center w-72">
<Typography variant="subtitle" className="mr-5 text-right h-full">
Language
</Typography>
<Select
isClearable={false}
onChange={onLanguageChangeHandler}
options={availableLanguages}
value={state.language}
/>
</div>
</Section>
<Section title="Hotkeys">
<HotkeysPreferences
disabled={disabled}
hotkeyDefinitions={state.hotkeyDefinitions}
onChange={onHotkeysChangeHandler}
errors={state.hotkeyErrors}
/>
</Section>
<div className="flex flex-row justify-between">
<Button variant="outlined" onClick={onResetHandler} disabled={disabled}>
{t('Reset to Defaults')}
</Button>
<div className="flex flex-row">
<Button variant="outlined" onClick={onCancelHandler}>
{t('Cancel')}
</Button>
<Button
variant="contained"
disabled={state.isDisabled}
color="light"
className="ml-2"
onClick={onSubmitHandler}
>
{t('Save')}
</Button>
</div>
</div>
</div>
);
};
const noop = () => { };
UserPreferences.propTypes = {
disabled: PropTypes.bool,
hotkeyDefaults: PropTypes.object.isRequired,
hotkeyDefinitions: PropTypes.object.isRequired,
languageOptions: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.any.isRequired,
})
),
onCancel: PropTypes.func,
onSubmit: PropTypes.func,
onReset: PropTypes.func,
};
UserPreferences.defaultProps = {
languageOptions: [
{ value: 'ONE', label: 'ONE' },
{ value: 'TWO', label: 'TWO' },
],
onCancel: noop,
onSubmit: noop,
onReset: noop,
disabled: true
};
export default UserPreferences;
@@ -0,0 +1,3 @@
import UserPreferences from './UserPreferences.jsx';
export default UserPreferences;
+10
View File
@@ -1,3 +1,4 @@
import AboutModal from './AboutModal';
import Button from './Button';
import ButtonGroup from './ButtonGroup';
import ContextMenu from './ContextMenu';
@@ -53,8 +54,17 @@ import ViewportActionBar from './ViewportActionBar';
import ViewportDownloadForm from './ViewportDownloadForm';
import ViewportGrid from './ViewportGrid';
import ViewportPane from './ViewportPane';
import UserPreferences from './UserPreferences';
import HotkeysPreferences from './HotkeysPreferences';
import HotkeyField from './HotkeyField';
import Header from './Header';
export {
AboutModal,
HotkeyField,
Header,
UserPreferences,
HotkeysPreferences,
Button,
ButtonGroup,
ContextMenu,