diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index 4b74124ba5..897b2ac854 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -6,6 +6,7 @@ import React, { useRef, useState } from 'react'; +import useKeyDownHandlers from '../../modules/IDE/hooks/useKeyDownHandlers'; import { MenuOpenContext, NavBarContext } from './contexts'; function NavBar({ children, className }) { @@ -31,18 +32,9 @@ function NavBar({ children, className }) { }; }, [nodeRef, setDropdownOpen]); - // TODO: replace with `useKeyDownHandlers` after #2052 is merged - useEffect(() => { - function handleKeyDown(e) { - if (e.keyCode === 27) { - setDropdownOpen('none'); - } - } - document.addEventListener('keydown', handleKeyDown, false); - return () => { - document.removeEventListener('keydown', handleKeyDown, false); - }; - }, [setDropdownOpen]); + useKeyDownHandlers({ + escape: () => setDropdownOpen('none') + }); const clearHideTimeout = useCallback(() => { if (timerRef.current) { diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index bddc2983e1..6fa1e8ef75 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -4,6 +4,7 @@ import { withTranslation } from 'react-i18next'; import browserHistory from '../../../browserHistory'; 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 5d1bbc88e0..876168e393 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..8a94cf10b2 --- /dev/null +++ b/client/modules/IDE/hooks/useKeyDownHandlers.js @@ -0,0 +1,60 @@ +import mapKeys from 'lodash/mapKeys'; +import PropTypes from 'prop-types'; +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 : e.ctrlKey; + if (e.shiftKey && isCtrl) { + handlers.current[`ctrl-shift-${e.key.toLowerCase()}`]?.(e); + } else 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. + * + * @param {Record void>} handlers + */ +export const DocumentKeyDown = ({ handlers }) => { + useKeyDownHandlers(handlers); + return null; +}; +DocumentKeyDown.propTypes = { + handlers: PropTypes.objectOf(PropTypes.func) +}; diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index f3e3628c24..a20a472db5 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -7,9 +7,9 @@ import { useTranslation, 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/Header/Toolbar'; import Preferences from '../components/Preferences/index'; import NewFileModal from '../components/NewFileModal'; import NewFolderModal from '../components/NewFolderModal'; @@ -17,7 +17,6 @@ import UploadFileModal from '../components/UploadFileModal'; import ShareModal from '../components/ShareModal'; import KeyboardShortcutModal from '../components/KeyboardShortcutModal'; import ErrorModal from '../components/ErrorModal'; -import Nav from '../components/Header/Nav'; import Console from '../components/Console'; import Toast from '../components/Toast'; import * as FileActions from '../actions/files'; @@ -81,7 +80,6 @@ export const CmControllerContext = React.createContext({}); class IDEView extends React.Component { constructor(props) { super(props); - this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this); this.state = { consoleSize: props.ide.consoleIsExpanded ? 150 : 29, @@ -102,9 +100,6 @@ class IDEView extends React.Component { } } - this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; - document.addEventListener('keydown', this.handleGlobalKeydown, false); - // window.onbeforeunload = this.handleUnsavedChanges; window.addEventListener('beforeunload', this.handleBeforeUnload); @@ -156,88 +151,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(); - } - } - } handleBeforeUnload = (e) => { const confirmationMessage = this.props.t('Nav.WarningUnsavedChanges'); @@ -259,6 +175,7 @@ class IDEView extends React.Component { {getTitle(this.props)} + this.cmController.getContent()} /> @@ -435,7 +352,6 @@ IDEView.propTypes = { id: PropTypes.string, username: PropTypes.string }).isRequired, - saveProject: PropTypes.func.isRequired, ide: PropTypes.shape({ errorType: PropTypes.string, keyboardShortcutVisible: PropTypes.bool.isRequired, @@ -482,7 +398,6 @@ IDEView.propTypes = { autocompleteHinter: PropTypes.bool.isRequired }).isRequired, closePreferences: PropTypes.func.isRequired, - setAllAccessibleOutput: PropTypes.func.isRequired, selectedFile: PropTypes.shape({ id: PropTypes.string.isRequired, content: PropTypes.string.isRequired, @@ -493,23 +408,13 @@ IDEView.propTypes = { name: PropTypes.string.isRequired, content: PropTypes.string.isRequired }).isRequired, - expandSidebar: PropTypes.func.isRequired, - collapseSidebar: PropTypes.func.isRequired, - cloneProject: PropTypes.func.isRequired, - expandConsole: PropTypes.func.isRequired, - collapseConsole: PropTypes.func.isRequired, updateFileContent: PropTypes.func.isRequired, - closeNewFolderModal: PropTypes.func.isRequired, - closeNewFileModal: PropTypes.func.isRequired, closeShareModal: PropTypes.func.isRequired, closeKeyboardShortcutModal: PropTypes.func.isRequired, autosaveProject: PropTypes.func.isRequired, setPreviousPath: PropTypes.func.isRequired, - showErrorModal: PropTypes.func.isRequired, hideErrorModal: PropTypes.func.isRequired, clearPersistedState: PropTypes.func.isRequired, - startSketch: PropTypes.func.isRequired, - closeUploadFileModal: PropTypes.func.isRequired, t: PropTypes.func.isRequired, isUserOwner: PropTypes.bool.isRequired }; diff --git a/client/modules/IDE/pages/MobileIDEView.jsx b/client/modules/IDE/pages/MobileIDEView.jsx index 57762b2e24..fc469fd87c 100644 --- a/client/modules/IDE/pages/MobileIDEView.jsx +++ b/client/modules/IDE/pages/MobileIDEView.jsx @@ -30,6 +30,7 @@ import UnsavedChangesDotIcon from '../../../images/unsaved-changes-dot.svg'; import IconButton from '../../../components/mobile/IconButton'; import Header from '../../../components/mobile/Header'; +import { useIDEKeyHandlers } from '../components/IDEKeyHandlers'; import Toast from '../components/Toast'; import Screen from '../../../components/mobile/MobileScreen'; import Footer from '../../../components/mobile/Footer'; @@ -44,12 +45,7 @@ import Dropdown from '../../../components/Dropdown'; import { selectActiveFile } from '../selectors/files'; import { getIsUserOwner } from '../selectors/users'; -import { - useEffectWithComparison, - useEventListener -} from '../hooks/custom-hooks'; - -import * as device from '../../../utils/device'; +import { useEffectWithComparison } from '../hooks/custom-hooks'; const withChangeDot = (title, unsavedChanges = false) => ( @@ -136,81 +132,6 @@ const getNavOptions = ( ]; }; -const canSaveProject = (isUserOwner, project, user) => - isUserOwner || (user.authenticated && !project.owner); - -// TODO: This could go into -const handleGlobalKeydown = (props, cmController) => (e) => { - const { - user, - project, - ide, - setAllAccessibleOutput, - saveProject, - cloneProject, - showErrorModal, - startSketch, - stopSketch, - expandSidebar, - collapseSidebar, - expandConsole, - collapseConsole, - closeNewFolderModal, - closeUploadFileModal, - closeNewFileModal, - isUserOwner - } = props; - - const isMac = device.isMac(); - - // const ctrlDown = (e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac); - const ctrlDown = isMac ? e.metaKey : e.ctrlKey; - - if (ctrlDown) { - if (e.shiftKey) { - if (e.keyCode === 13) { - e.preventDefault(); - e.stopPropagation(); - stopSketch(); - } else if (e.keyCode === 13) { - e.preventDefault(); - e.stopPropagation(); - startSketch(); - // 50 === 2 - } else if (e.keyCode === 50) { - e.preventDefault(); - setAllAccessibleOutput(false); - // 49 === 1 - } else if (e.keyCode === 49) { - e.preventDefault(); - setAllAccessibleOutput(true); - } - } else if (e.keyCode === 83) { - // 83 === s - e.preventDefault(); - e.stopPropagation(); - if (canSaveProject(isUserOwner, project, user)) - saveProject(cmController.getContent(), false, true); - else if (user.authenticated) cloneProject(); - else showErrorModal('forceAuthentication'); - - // 13 === enter - } else if (e.keyCode === 66) { - e.preventDefault(); - if (!ide.sidebarIsExpanded) expandSidebar(); - else collapseSidebar(); - } - } else if (e.keyCode === 192 && e.ctrlKey) { - e.preventDefault(); - if (ide.consoleIsExpanded) collapseConsole(); - else expandConsole(); - } else if (e.keyCode === 27) { - if (ide.newFolderModalVisible) closeNewFolderModal(); - else if (ide.uploadFileModalVisible) closeUploadFileModal(); - else if (ide.modalIsVisible) closeNewFileModal(); - } -}; - const autosave = (autosaveInterval, setAutosaveInterval) => ( props, prevProps @@ -338,9 +259,9 @@ const MobileIDEView = (props) => { isUserOwner }); - useEventListener('keydown', handleGlobalKeydown(props, cmController), false, [ - props - ]); + useIDEKeyHandlers({ + getContent: () => cmController.getContent() + }); const projectActions = [ { diff --git a/client/modules/IDE/selectors/users.js b/client/modules/IDE/selectors/users.js index 4bda34ef11..c794d65753 100644 --- a/client/modules/IDE/selectors/users.js +++ b/client/modules/IDE/selectors/users.js @@ -4,7 +4,7 @@ import getConfig from '../../../utils/getConfig'; export const getAuthenticated = (state) => state.user.authenticated; const getTotalSize = (state) => state.user.totalSize; const getAssetsTotalSize = (state) => state.assets.totalSize; -const getSketchOwner = (state) => state.project.owner; +export const getSketchOwner = (state) => state.project.owner; const getUserId = (state) => state.user.id; const limit = getConfig('UPLOAD_LIMIT') || 250000000;