From 3e944780dce1b7c2eb0be98e6bdf2f09f5c5bf8c Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Wed, 19 Aug 2020 16:02:59 -0300 Subject: [PATCH] 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 --- docs/latest/extensions/index.md | 1 + .../default/src/ViewerLayout/Header.jsx | 102 ------------- extensions/default/src/ViewerLayout/index.jsx | 48 ++++-- .../default/src/getLayoutTemplateModule.js | 4 +- modes/longitudinal/src/index.js | 1 + platform/core/src/classes/HotkeysManager.js | 30 +++- platform/core/src/defaults/hotkeyBindings.js | 71 +++++---- .../core/src/defaults/windowLevelPresets.js | 8 +- .../core/src/extensions/ExtensionManager.js | 9 +- platform/i18n/src/index.js | 8 +- ...{getAvailableLanguagesInfo.js => utils.js} | 8 +- platform/ui/index.js | 5 + .../src/components/AboutModal}/AboutModal.jsx | 18 +-- .../src/components/AboutModal/AboutModal.mdx | 30 ++++ .../ui/src/components/AboutModal/index.js | 2 + platform/ui/src/components/Button/Button.jsx | 3 + platform/ui/src/components/Header/Header.jsx | 71 +++++++++ platform/ui/src/components/Header/index.js | 2 + .../components/HotkeyField/HotkeyField.jsx | 63 ++++++++ .../ui/src/components/HotkeyField/index.js | 3 + .../ui/src/components/HotkeyField/utils.js | 31 ++++ .../HotkeysPreferences/HotkeysPreferences.jsx | 111 ++++++++++++++ .../HotkeysPreferences/hotkeysConfig.js | 91 +++++++++++ .../HotkeysPreferences/hotkeysValidators.js | 87 +++++++++++ .../components/HotkeysPreferences/index.js | 3 + .../components/HotkeysPreferences/utils.js | 52 +++++++ platform/ui/src/components/Input/Input.jsx | 17 ++- platform/ui/src/components/Select/Select.jsx | 6 +- .../UserPreferences/UserPreferences.jsx | 144 ++++++++++++++++++ .../src/components/UserPreferences/index.js | 3 + platform/ui/src/components/index.js | 10 ++ platform/viewer/src/appInit.js | 1 + .../PreferencesDropdown.jsx | 56 ------- .../components/PreferencesDropdown/index.js | 1 - platform/viewer/src/routes/Mode/Mode.jsx | 4 +- .../viewer/src/routes/WorkList/WorkList.jsx | 57 ++++--- platform/viewer/src/routes/index.js | 8 +- 37 files changed, 913 insertions(+), 256 deletions(-) delete mode 100644 extensions/default/src/ViewerLayout/Header.jsx rename platform/i18n/src/{getAvailableLanguagesInfo.js => utils.js} (88%) rename platform/{viewer/src/components/PreferencesDropdown => ui/src/components/AboutModal}/AboutModal.jsx (91%) create mode 100644 platform/ui/src/components/AboutModal/AboutModal.mdx create mode 100644 platform/ui/src/components/AboutModal/index.js create mode 100644 platform/ui/src/components/Header/Header.jsx create mode 100644 platform/ui/src/components/Header/index.js create mode 100644 platform/ui/src/components/HotkeyField/HotkeyField.jsx create mode 100644 platform/ui/src/components/HotkeyField/index.js create mode 100644 platform/ui/src/components/HotkeyField/utils.js create mode 100644 platform/ui/src/components/HotkeysPreferences/HotkeysPreferences.jsx create mode 100644 platform/ui/src/components/HotkeysPreferences/hotkeysConfig.js create mode 100644 platform/ui/src/components/HotkeysPreferences/hotkeysValidators.js create mode 100644 platform/ui/src/components/HotkeysPreferences/index.js create mode 100644 platform/ui/src/components/HotkeysPreferences/utils.js create mode 100644 platform/ui/src/components/UserPreferences/UserPreferences.jsx create mode 100644 platform/ui/src/components/UserPreferences/index.js delete mode 100644 platform/viewer/src/components/PreferencesDropdown/PreferencesDropdown.jsx delete mode 100644 platform/viewer/src/components/PreferencesDropdown/index.js diff --git a/docs/latest/extensions/index.md b/docs/latest/extensions/index.md index 6f33e7e06..de0659b36 100644 --- a/docs/latest/extensions/index.md +++ b/docs/latest/extensions/index.md @@ -172,6 +172,7 @@ project. const extensionManager = new ExtensionManager({ commandsManager, servicesManager, + hotkeysManager }); // prettier-ignore diff --git a/extensions/default/src/ViewerLayout/Header.jsx b/extensions/default/src/ViewerLayout/Header.jsx deleted file mode 100644 index 6000e5c22..000000000 --- a/extensions/default/src/ViewerLayout/Header.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useCallback } from 'react'; -import PropTypes from 'prop-types'; -import { useTranslation } from 'react-i18next'; -// TODO: This may fail if package is split from PWA build -import { useHistory } from 'react-router-dom'; -// -import { NavBar, Svg, Icon, IconButton, Dropdown, useModal } from '@ohif/ui'; - -function Header({ children }) { - const { t } = useTranslation(); - const history = useHistory(); - const { show } = useModal(); - - // TODO: IT SHOULD BE REFACTORED WHEN THE MODAL CONTENT IS DEFINED - const showAboutModal = useCallback(() => { - const modalComponent = () => ( -
{t('AboutModal:OHIF Viewer - About')}
- ); - show({ - title: t('AboutModal:OHIF Viewer - About'), - content: modalComponent, - }); - }, [show, t]); - - // TODO: IT SHOULD BE REFACTORED WHEN THE MODAL CONTENT IS DEFINED - const showPreferencesModal = useCallback(() => { - const modalComponent = () => ( -
{t('UserPreferencesModal:User Preferences')}
- ); - show({ - title: t('UserPreferencesModal:User Preferences'), - content: modalComponent, - }); - }, [show, t]); - - return ( - -
-
- {/* // TODO: Should preserve filter/sort - // Either injected service? Or context (like react router's `useLocation`?) */} -
history.push('/')} - > - -
- -
-
-
-
{children}
-
- - {t('Header:INVESTIGATIONAL USE ONLY')} - - - - - - - - - -
-
-
- ); -} - -Header.propTypes = { - children: PropTypes.any.isRequired, -}; - -export default Header; diff --git a/extensions/default/src/ViewerLayout/index.jsx b/extensions/default/src/ViewerLayout/index.jsx index ef6cabfcf..db5e38b1f 100644 --- a/extensions/default/src/ViewerLayout/index.jsx +++ b/extensions/default/src/ViewerLayout/index.jsx @@ -1,12 +1,13 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { SidePanel, ErrorBoundary } from '@ohif/ui'; -import Header from './Header.jsx'; +import { useTranslation } from 'react-i18next'; +import { SidePanel, ErrorBoundary, UserPreferences, AboutModal, Header, useModal } from '@ohif/ui'; + import NestedMenu from './ToolbarButtonNestedMenu.jsx'; // TODO: Having ToolbarPrimary and ToolbarSecondary is ugly, but // these are going to be unified shortly so this is good enough for now. -function ToolbarPrimary({servicesManager}) { +function ToolbarPrimary({ servicesManager }) { const { ToolBarService } = servicesManager.services; const defaultTool = { icon: 'tool-more-menu', @@ -82,7 +83,7 @@ function ToolbarPrimary({servicesManager}) { } -function ToolbarSecondary({servicesManager}) { +function ToolbarSecondary({ servicesManager }) { const { ToolBarService } = servicesManager.services; const defaultTool = { icon: 'tool-more-menu', @@ -137,19 +138,46 @@ function ToolbarSecondary({servicesManager}) { } - function ViewerLayout({ // From Extension Module Params extensionManager, servicesManager, - commandsManager, + hotkeysManager, // From Modes leftPanels, rightPanels, viewports, - children, ViewportGridComp, }) { + const { t } = useTranslation(); + const { show, hide } = useModal(); + + const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager; + const menuOptions = [ + { + title: t('Header:About'), + icon: 'info', + onClick: () => show({ content: AboutModal, title: 'About OHIF Viewer' }) + }, + { + title: t('Header:Preferences'), + icon: 'settings', + onClick: () => show({ + title: t('UserPreferencesModal:User Preferences'), + content: UserPreferences, + contentProps: { + hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults), + hotkeyDefinitions, + onCancel: hide, + onSubmit: ({ hotkeyDefinitions }) => { + hotkeysManager.setHotkeys(hotkeyDefinitions); + hide(); + }, + onReset: () => hotkeysManager.restoreDefaultBindings() + } + }) + }, + ]; /** * Set body classes (tailwindcss) that don't allow vertical @@ -194,10 +222,10 @@ function ViewerLayout({ return (
-
+
- +
@@ -220,7 +248,7 @@ function ViewerLayout({
- +
diff --git a/extensions/default/src/getLayoutTemplateModule.js b/extensions/default/src/getLayoutTemplateModule.js index e70d961c1..793f002fa 100644 --- a/extensions/default/src/getLayoutTemplateModule.js +++ b/extensions/default/src/getLayoutTemplateModule.js @@ -5,16 +5,18 @@ import ViewerLayout from './ViewerLayout'; - Init layout based on the displaySets and the objects. */ -export default function({ +export default function ({ servicesManager, extensionManager, commandsManager, + hotkeysManager }) { function ViewerLayoutWithServices(props) { return ViewerLayout({ servicesManager, extensionManager, commandsManager, + hotkeysManager, ...props, }); } diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index ca7dad86a..97648fd6b 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -5,6 +5,7 @@ const ohif = { layout: 'org.ohif.default.layoutTemplateModule.viewerLayout', sopClassHandler: 'org.ohif.default.sopClassHandlerModule.stack', }; + const tracked = { measurements: 'org.ohif.measurement-tracking.panelModule.trackedMeasurements', thumbnailList: 'org.ohif.measurement-tracking.panelModule.seriesList', diff --git a/platform/core/src/classes/HotkeysManager.js b/platform/core/src/classes/HotkeysManager.js index 0848e0437..1ede273b0 100644 --- a/platform/core/src/classes/HotkeysManager.js +++ b/platform/core/src/classes/HotkeysManager.js @@ -61,8 +61,7 @@ export class HotkeysManager { */ setHotkeys(hotkeyDefinitions = []) { try { - const definitions = this._getValidDefinitions(hotkeyDefinitions); - + const definitions = this.getValidDefinitions(hotkeyDefinitions); definitions.forEach(definition => this.registerHotkeys(definition)); } catch (error) { const { UINotificationService } = this._servicesManager.services; @@ -81,8 +80,7 @@ export class HotkeysManager { * @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions */ setDefaultHotKeys(hotkeyDefinitions = []) { - const definitions = this._getValidDefinitions(hotkeyDefinitions); - + const definitions = this.getValidDefinitions(hotkeyDefinitions); this.hotkeyDefaults = definitions; } @@ -92,7 +90,7 @@ export class HotkeysManager { * * @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions */ - _getValidDefinitions(hotkeyDefinitions) { + getValidDefinitions(hotkeyDefinitions) { const definitions = Array.isArray(hotkeyDefinitions) ? [...hotkeyDefinitions] : this._parseToArrayLike(hotkeyDefinitions); @@ -100,6 +98,24 @@ export class HotkeysManager { return definitions; } + /** + * Take hotkey definitions that can be an array and make sure that it + * returns an object of hotkeys definitions + * + * @param {HotkeyDefinition[]} [hotkeyDefinitions=[]] Contains hotkeys definitions + * @returns {Object} + */ + getValidHotkeyDefinitions(hotkeyDefinitions) { + const definitions = this.getValidDefinitions(hotkeyDefinitions); + const objectDefinitions = {}; + definitions.forEach(definition => { + const { commandName, commandOptions } = definition; + const commandHash = objectHash({ commandName, commandOptions }); + objectDefinitions[commandHash] = definition; + }); + return objectDefinitions; + } + /** * It parses given object containing hotkeyDefinition to array like. * Each property of given object will be mapped to an object of an array. And its property name will be the value of a property named as commandName @@ -147,7 +163,7 @@ export class HotkeysManager { * @param {String} extension * @returns {undefined} */ - registerHotkeys({ commandName, commandOptions = {}, keys, label } = {}, extension) { + registerHotkeys({ commandName, commandOptions = {}, keys, label, isEditable } = {}, extension) { if (!commandName) { log.warn(`[hotkeys] No command was defined for hotkey "${keys}"`); return; @@ -164,7 +180,7 @@ export class HotkeysManager { } // Set definition & bind - this.hotkeyDefinitions[commandHash] = { keys, label }; + this.hotkeyDefinitions[commandHash] = { commandName, commandOptions, keys, label, isEditable }; this._bindHotkeys(commandName, commandOptions, keys); log.info(`[hotkeys] Binding ${commandName} with ${options} options to ${keys}`); } diff --git a/platform/core/src/defaults/hotkeyBindings.js b/platform/core/src/defaults/hotkeyBindings.js index fd0ede4bf..df7346d58 100644 --- a/platform/core/src/defaults/hotkeyBindings.js +++ b/platform/core/src/defaults/hotkeyBindings.js @@ -4,53 +4,64 @@ import windowLevelPresets from './windowLevelPresets'; * Supported Keys: https://craig.is/killing/mice */ export default [ - /** Global */ - { - commandName: 'incrementActiveViewport', - label: 'Next Viewport', - keys: ['right'], - }, - { - commandName: 'decrementActiveViewport', - label: 'Previous Viewport', - keys: ['left'], - }, - /** Viewport */ - { commandName: 'rotateViewportCW', label: 'Rotate Right', keys: ['r'] }, - { commandName: 'rotateViewportCCW', label: 'Rotate Left', keys: ['l'] }, - { commandName: 'invertViewport', label: 'Invert', keys: ['i'] }, - { - commandName: 'cancelMeasurement', - label: 'Cancel Cornerstone Measurement', - keys: ['esc'], - }, + { commandName: 'setToolActive', commandOptions: { toolName: 'Zoom' }, label: 'Zoom', keys: ['z'], isEditable: true }, + { commandName: 'scaleUpViewport', label: 'Zoom In', keys: ['+'], isEditable: true }, + { commandName: 'scaleDownViewport', label: 'Zoom Out', keys: ['-'], isEditable: true }, + { commandName: 'fitViewportToWindow', label: 'Zoom to Fit', keys: ['='], isEditable: true }, + { commandName: 'rotateViewportCW', label: 'Rotate Right', keys: ['r'], isEditable: true }, + { commandName: 'rotateViewportCCW', label: 'Rotate Left', keys: ['l'], isEditable: true }, { commandName: 'flipViewportVertical', label: 'Flip Horizontally', keys: ['h'], + isEditable: true, }, { commandName: 'flipViewportHorizontal', label: 'Flip Vertically', keys: ['v'], + isEditable: true, }, - { commandName: 'scaleUpViewport', label: 'Zoom In', keys: ['+'] }, - { commandName: 'scaleDownViewport', label: 'Zoom Out', keys: ['-'] }, - { commandName: 'fitViewportToWindow', label: 'Zoom to Fit', keys: ['='] }, - { commandName: 'resetViewport', label: 'Reset', keys: ['space'] }, - { commandName: 'nextImage', label: 'Next Image', keys: ['down'] }, - { commandName: 'previousImage', label: 'Previous Image', keys: ['up'] }, { - commandName: 'previousViewportDisplaySet', - label: 'Previous Series', - keys: ['pagedown'], + commandName: 'invertViewport', + label: 'Invert', + keys: ['i'], + isEditable: true, + }, + { + commandName: 'incrementActiveViewport', + label: 'Next Image Viewport', + keys: ['right'], + isEditable: true, + }, + { + commandName: 'decrementActiveViewport', + label: 'Previous Image Viewport', + keys: ['left'], + isEditable: true, }, { commandName: 'nextViewportDisplaySet', label: 'Next Series', keys: ['pageup'], + isEditable: true, + }, + { + commandName: 'previousViewportDisplaySet', + label: 'Previous Series', + keys: ['pagedown'], + isEditable: true, + }, + { commandName: 'nextImage', label: 'Next Image', keys: ['down'], isEditable: true }, + { commandName: 'previousImage', label: 'Previous Image', keys: ['up'], isEditable: true }, + { commandName: 'firstImage', label: 'First Image', keys: ['home'], isEditable: true }, + { commandName: 'lastImage', label: 'Last Image', keys: ['end'], isEditable: true }, + { commandName: 'resetViewport', label: 'Reset', keys: ['space'], isEditable: true }, + { + commandName: 'cancelMeasurement', + label: 'Cancel Cornerstone Measurement', + keys: ['esc'], }, - /** Window level presets */ { commandName: 'setWindowLevel', commandOptions: windowLevelPresets[1], diff --git a/platform/core/src/defaults/windowLevelPresets.js b/platform/core/src/defaults/windowLevelPresets.js index ea2ae3b8d..258b3d18a 100644 --- a/platform/core/src/defaults/windowLevelPresets.js +++ b/platform/core/src/defaults/windowLevelPresets.js @@ -5,8 +5,8 @@ export default { 4: { description: 'Bone', window: '80', level: '40' }, 5: { description: 'Brain', window: '2500', level: '480' }, 6: { description: 'Trest', window: '1', level: '1' }, - 7: { description: '', window: '', level: '' }, - 8: { description: '', window: '', level: '' }, - 9: { description: '', window: '', level: '' }, - 10: { description: '', window: '', level: '' }, + 7: { description: 'Empty1', window: 'Empty1', level: 'Empty1' }, + 8: { description: 'Empty2', window: 'Empty2', level: 'Empty2' }, + 9: { description: 'Empty3', window: 'Empty3', level: 'Empty3' }, + 10: { description: 'Empty4', window: 'Empty4', level: 'Empty4' }, }; diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js index 07249389b..7ed8a1eba 100644 --- a/platform/core/src/extensions/ExtensionManager.js +++ b/platform/core/src/extensions/ExtensionManager.js @@ -2,13 +2,14 @@ import MODULE_TYPES from './MODULE_TYPES.js'; import log from './../log.js'; export default class ExtensionManager { - constructor({ commandsManager, servicesManager, api, appConfig = {} }) { + constructor({ commandsManager, servicesManager, hotkeysManager, api, appConfig = {} }) { this.modules = {}; this.registeredExtensionIds = []; this.moduleTypeNames = Object.values(MODULE_TYPES); // this._commandsManager = commandsManager; this._servicesManager = servicesManager; + this._hotkeysManager = hotkeysManager; this._appConfig = appConfig; this._api = api; @@ -31,6 +32,7 @@ export default class ExtensionManager { registeredExtensionIds, _servicesManager, _commandsManager, + _hotkeysManager, _extensionLifeCycleHooks, } = this; @@ -51,6 +53,7 @@ export default class ExtensionManager { onModeEnter({ servicesManager: _servicesManager, commandsManager: _commandsManager, + hotkeysManager: _hotkeysManager }); } }); @@ -139,6 +142,7 @@ export default class ExtensionManager { extension.preRegistration({ servicesManager: this._servicesManager, commandsManager: this._commandsManager, + hotkeysManager: this._hotkeysManager, appConfig: this._appConfig, configuration, }); @@ -239,8 +243,9 @@ export default class ExtensionManager { appConfig: this._appConfig, getDataSources: this.getDataSources, // Why pass this in if we're passing in `extensionManager`? commandsManager: this._commandsManager, - extensionManager: this, servicesManager: this._servicesManager, + hotkeysManager: this._hotkeysManager, + extensionManager: this, configuration, api: this._api, }); diff --git a/platform/i18n/src/index.js b/platform/i18n/src/index.js index 59f2ae76e..482b53b7e 100644 --- a/platform/i18n/src/index.js +++ b/platform/i18n/src/index.js @@ -7,6 +7,7 @@ import { initReactI18next } from 'react-i18next'; import customDebug from './debugger'; import pkg from '../package.json'; import { debugMode, detectionOptions } from './config'; +import { getLanguageLabel, getAvailableLanguagesInfo } from './utils.js'; // Note: The index.js files inside src/locales are dynamically generated // by the pullTranslations.sh script @@ -127,7 +128,7 @@ function initI18n( }); } - return initialized.then(function(t) { + return initialized.then(function (t) { i18n.T = t; customDebug(`T function available.`, 'info'); }); @@ -138,9 +139,8 @@ customDebug(`version ${pkg.version} loaded.`, 'info'); i18n.initializing = initI18n(); i18n.initI18n = initI18n; i18n.addLocales = addLocales; -i18n.defaultLanguage = DEFAULT_LANGUAGE; - -import getAvailableLanguagesInfo from './getAvailableLanguagesInfo.js'; i18n.availableLanguages = getAvailableLanguagesInfo(locales); +i18n.defaultLanguage = { label: getLanguageLabel(DEFAULT_LANGUAGE), value: DEFAULT_LANGUAGE }; +i18n.currentLanguage = () => ({ label: getLanguageLabel(i18n.language), value: i18n.language }); export default i18n; diff --git a/platform/i18n/src/getAvailableLanguagesInfo.js b/platform/i18n/src/utils.js similarity index 88% rename from platform/i18n/src/getAvailableLanguagesInfo.js rename to platform/i18n/src/utils.js index 8dd1f257a..b62eedca1 100644 --- a/platform/i18n/src/getAvailableLanguagesInfo.js +++ b/platform/i18n/src/utils.js @@ -56,15 +56,21 @@ const languagesMap = { 'zh-TW': 'Chinese (Taiwan)', }; +const getLanguageLabel = (language) => { + return languagesMap[language]; +}; + export default function getAvailableLanguagesInfo(locales) { const availableLanguagesInfo = []; Object.keys(locales).forEach(key => { availableLanguagesInfo.push({ value: key, - label: languagesMap[key] || key, + label: getLanguageLabel(key) || key, }); }); return availableLanguagesInfo; } + +export { getAvailableLanguagesInfo, getLanguageLabel }; diff --git a/platform/ui/index.js b/platform/ui/index.js index 81379ac85..8c1bde12d 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -30,6 +30,11 @@ export { /** COMPONENTS */ export { + AboutModal, + HotkeyField, + Header, + UserPreferences, + HotkeysPreferences, Button, ButtonGroup, ContextMenu, diff --git a/platform/viewer/src/components/PreferencesDropdown/AboutModal.jsx b/platform/ui/src/components/AboutModal/AboutModal.jsx similarity index 91% rename from platform/viewer/src/components/PreferencesDropdown/AboutModal.jsx rename to platform/ui/src/components/AboutModal/AboutModal.jsx index af06e2bee..4f515a615 100644 --- a/platform/viewer/src/components/PreferencesDropdown/AboutModal.jsx +++ b/platform/ui/src/components/AboutModal/AboutModal.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Typography, Icon } from '@ohif/ui'; +import { Typography, Icon } from '../../components'; import detect from 'browser-detect'; const Link = ({ href, children, showIcon = false }) => { @@ -29,14 +29,14 @@ const Row = ({ title, value, link }) => { {link ? ( {value} ) : ( - - {value} - - )} + + {value} + + )}
); }; diff --git a/platform/ui/src/components/AboutModal/AboutModal.mdx b/platform/ui/src/components/AboutModal/AboutModal.mdx new file mode 100644 index 000000000..917e20e66 --- /dev/null +++ b/platform/ui/src/components/AboutModal/AboutModal.mdx @@ -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 + + +
+ +
+
+ +## Properties + + diff --git a/platform/ui/src/components/AboutModal/index.js b/platform/ui/src/components/AboutModal/index.js new file mode 100644 index 000000000..a325d7555 --- /dev/null +++ b/platform/ui/src/components/AboutModal/index.js @@ -0,0 +1,2 @@ +import AboutModal from './AboutModal'; +export default AboutModal; diff --git a/platform/ui/src/components/Button/Button.jsx b/platform/ui/src/components/Button/Button.jsx index fdd5d9390..0efe7a11d 100644 --- a/platform/ui/src/components/Button/Button.jsx +++ b/platform/ui/src/components/Button/Button.jsx @@ -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, diff --git a/platform/ui/src/components/Header/Header.jsx b/platform/ui/src/components/Header/Header.jsx new file mode 100644 index 000000000..8b0a30f3b --- /dev/null +++ b/platform/ui/src/components/Header/Header.jsx @@ -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 ( + +
+
+ {/* // TODO: Should preserve filter/sort + // Either injected service? Or context (like react router's `useLocation`?) */} +
+ {isReturnEnabled && } +
+
+
+
{children}
+
+ + {t('Header:INVESTIGATIONAL USE ONLY')} + + + + + + + + + +
+
+
+ ); +} + +Header.propTypes = { + children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + isReturnEnabled: PropTypes.bool +}; + +Header.defaultProps = { + isReturnEnabled: true +}; + +export default Header; diff --git a/platform/ui/src/components/Header/index.js b/platform/ui/src/components/Header/index.js new file mode 100644 index 000000000..24421ce68 --- /dev/null +++ b/platform/ui/src/components/Header/index.js @@ -0,0 +1,2 @@ +import Header from './Header'; +export default Header; diff --git a/platform/ui/src/components/HotkeyField/HotkeyField.jsx b/platform/ui/src/components/HotkeyField/HotkeyField.jsx new file mode 100644 index 000000000..40bdbc362 --- /dev/null +++ b/platform/ui/src/components/HotkeyField/HotkeyField.jsx @@ -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 ( + + ); +}; + +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; diff --git a/platform/ui/src/components/HotkeyField/index.js b/platform/ui/src/components/HotkeyField/index.js new file mode 100644 index 000000000..890117cf6 --- /dev/null +++ b/platform/ui/src/components/HotkeyField/index.js @@ -0,0 +1,3 @@ +import HotkeyField from './HotkeyField.jsx'; + +export default HotkeyField; diff --git a/platform/ui/src/components/HotkeyField/utils.js b/platform/ui/src/components/HotkeyField/utils.js new file mode 100644 index 000000000..fe1c7d064 --- /dev/null +++ b/platform/ui/src/components/HotkeyField/utils.js @@ -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 +}; diff --git a/platform/ui/src/components/HotkeysPreferences/HotkeysPreferences.jsx b/platform/ui/src/components/HotkeysPreferences/HotkeysPreferences.jsx new file mode 100644 index 000000000..ab700ec80 --- /dev/null +++ b/platform/ui/src/components/HotkeysPreferences/HotkeysPreferences.jsx @@ -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 ( +
+
+ {splitedHotkeys.map((hotkeys, index) => { + return ( +
+
+ {hotkeys.map((hotkey, hotkeyIndex) => { + const [id, definition] = hotkey; + const isFirst = hotkeyIndex === 0; + const error = errors[id]; + + const onChangeHandler = keys => onHotkeyChangeHandler(id, { ...definition, keys }); + + return ( +
+
+ + Function + + + {definition.label} + +
+
+ + Shortcut + +
+ + {error && {error}} +
+
+
+ ); + })} +
+
+ ); + })} +
+
+ ); +}; + +const noop = () => { }; + +HotkeysPreferences.propTypes = { + onChange: PropTypes.func, + disabled: PropTypes.bool, + hotkeyDefinitions: PropTypes.object.isRequired, +}; + +HotkeysPreferences.defaultProps = { + onChange: noop, + disabled: false +}; + +export default HotkeysPreferences; diff --git a/platform/ui/src/components/HotkeysPreferences/hotkeysConfig.js b/platform/ui/src/components/HotkeysPreferences/hotkeysConfig.js new file mode 100644 index 000000000..23d286b19 --- /dev/null +++ b/platform/ui/src/components/HotkeysPreferences/hotkeysConfig.js @@ -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', +}; diff --git a/platform/ui/src/components/HotkeysPreferences/hotkeysValidators.js b/platform/ui/src/components/HotkeysPreferences/hotkeysValidators.js new file mode 100644 index 000000000..fbb2c1004 --- /dev/null +++ b/platform/ui/src/components/HotkeysPreferences/hotkeysValidators.js @@ -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 }; diff --git a/platform/ui/src/components/HotkeysPreferences/index.js b/platform/ui/src/components/HotkeysPreferences/index.js new file mode 100644 index 000000000..d3f9621c7 --- /dev/null +++ b/platform/ui/src/components/HotkeysPreferences/index.js @@ -0,0 +1,3 @@ +import HotkeysPreferences from './HotkeysPreferences.jsx'; + +export default HotkeysPreferences; diff --git a/platform/ui/src/components/HotkeysPreferences/utils.js b/platform/ui/src/components/HotkeysPreferences/utils.js new file mode 100644 index 000000000..3b0fa0aef --- /dev/null +++ b/platform/ui/src/components/HotkeysPreferences/utils.js @@ -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 +}; diff --git a/platform/ui/src/components/Input/Input.jsx b/platform/ui/src/components/Input/Input.jsx index 96f17001c..af981ae2a 100644 --- a/platform/ui/src/components/Input/Input.jsx +++ b/platform/ui/src/components/Input/Input.jsx @@ -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 = ({ @@ -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; diff --git a/platform/ui/src/components/Select/Select.jsx b/platform/ui/src/components/Select/Select.jsx index 0cc410a46..2cb76d728 100644 --- a/platform/ui/src/components/Select/Select.jsx +++ b/platform/ui/src/components/Select/Select.jsx @@ -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); }} - > + /> ); }; @@ -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; diff --git a/platform/ui/src/components/UserPreferences/UserPreferences.jsx b/platform/ui/src/components/UserPreferences/UserPreferences.jsx new file mode 100644 index 000000000..a3ecf1743 --- /dev/null +++ b/platform/ui/src/components/UserPreferences/UserPreferences.jsx @@ -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 }) => ( + <> +
+ + {title} + +
+
+ {children} +
+ + ); + + return ( +
+
+
+ + Language + +