From 9597861b4df11960d2a05846767377931754406b Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sun, 7 Aug 2022 14:31:25 -0500 Subject: [PATCH 1/6] Move keydown handling out of IDEView --- client/components/Nav.jsx | 16 ++-- client/modules/App/components/Overlay.jsx | 13 +-- .../modules/IDE/components/IDEKeyHandlers.jsx | 93 ++++++++++++++++++ client/modules/IDE/components/Modal.jsx | 3 + .../modules/IDE/hooks/useKeyDownHandlers.js | 55 +++++++++++ client/modules/IDE/pages/IDEView.jsx | 96 +------------------ client/modules/IDE/pages/MobileIDEView.jsx | 89 +---------------- client/modules/IDE/selectors/users.js | 4 +- 8 files changed, 168 insertions(+), 201 deletions(-) create mode 100644 client/modules/IDE/components/IDEKeyHandlers.jsx create mode 100644 client/modules/IDE/hooks/useKeyDownHandlers.js diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx index a7b612a20d..12dc6fdb4d 100644 --- a/client/components/Nav.jsx +++ b/client/components/Nav.jsx @@ -13,6 +13,7 @@ import { setAllAccessibleOutput, setLanguage } from '../modules/IDE/actions/preferences'; +import { DocumentKeyDown } from '../modules/IDE/hooks/useKeyDownHandlers'; import { logoutUser } from '../modules/User/actions'; import getConfig from '../utils/getConfig'; @@ -63,17 +64,13 @@ class Nav extends React.PureComponent { this.toggleDropdownForLang = this.toggleDropdown.bind(this, 'lang'); this.handleFocusForLang = this.handleFocus.bind(this, 'lang'); this.handleLangSelection = this.handleLangSelection.bind(this); - - this.closeDropDown = this.closeDropDown.bind(this); } componentDidMount() { document.addEventListener('mousedown', this.handleClick, false); - document.addEventListener('keydown', this.closeDropDown, false); } componentWillUnmount() { document.removeEventListener('mousedown', this.handleClick, false); - document.removeEventListener('keydown', this.closeDropDown, false); } setDropdown(dropdown) { this.setState({ @@ -81,12 +78,6 @@ class Nav extends React.PureComponent { }); } - closeDropDown(e) { - if (e.keyCode === 27) { - this.setDropdown('none'); - } - } - handleClick(e) { if (!this.node) { return; @@ -904,6 +895,11 @@ class Nav extends React.PureComponent { {this.renderLeftLayout(navDropdownState)} {this.renderUserMenu(navDropdownState)} + this.setDropdown('none') + }} + /> ); } diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index 39f492fc6f..53bda34f48 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -4,6 +4,7 @@ import { browserHistory } from 'react-router'; import { withTranslation } from 'react-i18next'; import ExitIcon from '../../../images/exit.svg'; +import { DocumentKeyDown } from '../../IDE/hooks/useKeyDownHandlers'; class Overlay extends React.Component { constructor(props) { @@ -11,12 +12,10 @@ class Overlay extends React.Component { this.close = this.close.bind(this); this.handleClick = this.handleClick.bind(this); this.handleClickOutside = this.handleClickOutside.bind(this); - this.keyPressHandle = this.keyPressHandle.bind(this); } componentWillMount() { document.addEventListener('mousedown', this.handleClick, false); - document.addEventListener('keydown', this.keyPressHandle); } componentDidMount() { @@ -25,7 +24,6 @@ class Overlay extends React.Component { componentWillUnmount() { document.removeEventListener('mousedown', this.handleClick, false); - document.removeEventListener('keydown', this.keyPressHandle); } handleClick(e) { @@ -40,14 +38,6 @@ class Overlay extends React.Component { this.close(); } - keyPressHandle(e) { - // escape key code = 27. - // So here we are checking if the key pressed was Escape key. - if (e.keyCode === 27) { - this.close(); - } - } - close() { // Only close if it is the last (and therefore the topmost overlay) const overlays = document.getElementsByClassName('overlay'); @@ -90,6 +80,7 @@ class Overlay extends React.Component { {children} + this.close() }} /> diff --git a/client/modules/IDE/components/IDEKeyHandlers.jsx b/client/modules/IDE/components/IDEKeyHandlers.jsx new file mode 100644 index 0000000000..6578753f88 --- /dev/null +++ b/client/modules/IDE/components/IDEKeyHandlers.jsx @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateFileContent } from '../actions/files'; +import { + collapseConsole, + collapseSidebar, + expandConsole, + expandSidebar, + showErrorModal, + startSketch, + stopSketch +} from '../actions/ide'; +import { setAllAccessibleOutput } from '../actions/preferences'; +import { cloneProject, saveProject } from '../actions/project'; +import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; +import { + getAuthenticated, + getIsUserOwner, + getSketchOwner +} from '../selectors/users'; + +export const useIDEKeyHandlers = ({ getContent }) => { + const dispatch = useDispatch(); + + const sidebarIsExpanded = useSelector((state) => state.ide.sidebarIsExpanded); + const consoleIsExpanded = useSelector((state) => state.ide.consoleIsExpanded); + + const isUserOwner = useSelector(getIsUserOwner); + const isAuthenticated = useSelector(getAuthenticated); + const sketchOwner = useSelector(getSketchOwner); + + const syncFileContent = () => { + const file = getContent(); + dispatch(updateFileContent(file.id, file.content)); + }; + + useKeyDownHandlers({ + 'ctrl-s': (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isUserOwner || (isAuthenticated && !sketchOwner)) { + dispatch(saveProject(getContent())); + } else if (isAuthenticated) { + dispatch(cloneProject()); + } else { + dispatch(showErrorModal('forceAuthentication')); + } + }, + 'ctrl-shift-enter': (e) => { + e.preventDefault(); + e.stopPropagation(); + dispatch(stopSketch()); + }, + 'ctrl-enter': (e) => { + e.preventDefault(); + e.stopPropagation(); + syncFileContent(); + dispatch(startSketch()); + }, + 'ctrl-shift-1': (e) => { + e.preventDefault(); + dispatch(setAllAccessibleOutput(true)); + }, + 'ctrl-shift-2': (e) => { + e.preventDefault(); + dispatch(setAllAccessibleOutput(false)); + }, + 'ctrl-b': (e) => { + e.preventDefault(); + dispatch( + // TODO: create actions 'toggleConsole', 'toggleSidebar', etc. + sidebarIsExpanded ? collapseSidebar() : expandSidebar() + ); + }, + 'ctrl-`': (e) => { + e.preventDefault(); + dispatch(consoleIsExpanded ? collapseConsole() : expandConsole()); + } + }); +}; + +const IDEKeyHandlers = ({ getContent }) => { + useIDEKeyHandlers({ getContent }); + return null; +}; + +// Most actions can be accessed via redux, but those involving the cmController +// must be provided via props. +IDEKeyHandlers.propTypes = { + getContent: PropTypes.func.isRequired +}; + +export default IDEKeyHandlers; diff --git a/client/modules/IDE/components/Modal.jsx b/client/modules/IDE/components/Modal.jsx index cdbd217327..9b9cd2d614 100644 --- a/client/modules/IDE/components/Modal.jsx +++ b/client/modules/IDE/components/Modal.jsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { useEffect, useRef } from 'react'; import ExitIcon from '../../../images/exit.svg'; +import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; // Common logic from NewFolderModal, NewFileModal, UploadFileModal @@ -30,6 +31,8 @@ const Modal = ({ }; }, []); + useKeyDownHandlers({ escape: onClose }); + return (
diff --git a/client/modules/IDE/hooks/useKeyDownHandlers.js b/client/modules/IDE/hooks/useKeyDownHandlers.js new file mode 100644 index 0000000000..e943f270ea --- /dev/null +++ b/client/modules/IDE/hooks/useKeyDownHandlers.js @@ -0,0 +1,55 @@ +import mapKeys from 'lodash/mapKeys'; +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Attaches keydown handlers to the global document. + * + * Handles Mac/PC switching of Ctrl to Cmd. + * + * @param {Record void>} keyHandlers - an object + * which maps from the key to its event handler. The object keys are a combination + * of the key and prefixes `ctrl-` `shift-` (ie. 'ctrl-f', 'ctrl-shift-f') + * and the values are the function to call when that specific key is pressed. + */ +export default function useKeyDownHandlers(keyHandlers) { + /** + * Instead of memoizing the handlers, use a ref and call the current + * handler at the time of the event. + */ + const handlers = useRef(keyHandlers); + + useEffect(() => { + handlers.current = mapKeys(keyHandlers, (value, key) => key.toLowerCase()); + }, [keyHandlers]); + + /** + * Will call all matching handlers, starting with the most specific: 'ctrl-shift-f' => 'ctrl-f' => 'f'. + * Can use e.stopPropagation() to prevent subsequent handlers. + * @type {(function(KeyboardEvent): void)} + */ + const handleEvent = useCallback((e) => { + const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; + const isCtrl = isMac ? e.metaKey && this.isMac : e.ctrlKey; + if (e.shiftKey && isCtrl) { + handlers.current[`ctrl-shift-${e.key.toLowerCase()}`]?.(e); + } + if (isCtrl) { + handlers.current[`ctrl-${e.key.toLowerCase()}`]?.(e); + } + handlers.current[e.key.toLowerCase()]?.(e); + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleEvent); + + return () => document.removeEventListener('keydown', handleEvent); + }, [handleEvent]); +} + +/** + * Component version can be used in class components where hooks can't be used. + */ +export const DocumentKeyDown = ({ handlers }) => { + useKeyDownHandlers(handlers); + return null; +}; diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 05870c8de9..5469b183de 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -7,6 +7,7 @@ import { withTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet'; import SplitPane from 'react-split-pane'; import Editor from '../components/Editor'; +import IDEKeyHandlers from '../components/IDEKeyHandlers'; import Sidebar from '../components/Sidebar'; import PreviewFrame from '../components/PreviewFrame'; import Toolbar from '../components/Toolbar'; @@ -63,7 +64,6 @@ function warnIfUnsavedChanges(props, nextLocation) { class IDEView extends React.Component { constructor(props) { super(props); - this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this); this.state = { consoleSize: props.ide.consoleIsExpanded ? 150 : 29, @@ -84,9 +84,6 @@ class IDEView extends React.Component { } } - this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; - document.addEventListener('keydown', this.handleGlobalKeydown, false); - this.props.router.setRouteLeaveHook( this.props.route, this.handleUnsavedChanges @@ -149,88 +146,9 @@ class IDEView extends React.Component { } } componentWillUnmount() { - document.removeEventListener('keydown', this.handleGlobalKeydown, false); clearTimeout(this.autosaveInterval); this.autosaveInterval = null; } - handleGlobalKeydown(e) { - // 83 === s - if ( - e.keyCode === 83 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - if ( - this.props.isUserOwner || - (this.props.user.authenticated && !this.props.project.owner) - ) { - this.props.saveProject(this.cmController.getContent()); - } else if (this.props.user.authenticated) { - this.props.cloneProject(); - } else { - this.props.showErrorModal('forceAuthentication'); - } - // 13 === enter - } else if ( - e.keyCode === 13 && - e.shiftKey && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - this.props.stopSketch(); - } else if ( - e.keyCode === 13 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - this.syncFileContent(); - this.props.startSketch(); - // 50 === 2 - } else if ( - e.keyCode === 50 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && - e.shiftKey - ) { - e.preventDefault(); - this.props.setAllAccessibleOutput(false); - // 49 === 1 - } else if ( - e.keyCode === 49 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && - e.shiftKey - ) { - e.preventDefault(); - this.props.setAllAccessibleOutput(true); - } else if ( - e.keyCode === 66 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - if (!this.props.ide.sidebarIsExpanded) { - this.props.expandSidebar(); - } else { - this.props.collapseSidebar(); - } - } else if (e.keyCode === 192 && e.ctrlKey) { - e.preventDefault(); - if (this.props.ide.consoleIsExpanded) { - this.props.collapseConsole(); - } else { - this.props.expandConsole(); - } - } else if (e.keyCode === 27) { - if (this.props.ide.newFolderModalVisible) { - this.props.closeNewFolderModal(); - } else if (this.props.ide.uploadFileModalVisible) { - this.props.closeUploadFileModal(); - } else if (this.props.ide.modalIsVisible) { - this.props.closeNewFileModal(); - } - } - } handleUnsavedChanges = (nextLocation) => warnIfUnsavedChanges(this.props, nextLocation); @@ -255,6 +173,7 @@ class IDEView extends React.Component { {getTitle(this.props)} + this.cmController.getContent()} /> {this.props.toast.isVisible && }