Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move keydown handling out of IDEView #2052

Merged
merged 16 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 4 additions & 12 deletions client/components/Nav/NavBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand All @@ -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) {
Expand Down
13 changes: 2 additions & 11 deletions client/modules/App/components/Overlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,18 @@ 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) {
super(props);
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() {
Expand All @@ -25,7 +24,6 @@ class Overlay extends React.Component {

componentWillUnmount() {
document.removeEventListener('mousedown', this.handleClick, false);
document.removeEventListener('keydown', this.keyPressHandle);
}

handleClick(e) {
Expand All @@ -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');
Expand Down Expand Up @@ -90,6 +80,7 @@ class Overlay extends React.Component {
</div>
</header>
{children}
<DocumentKeyDown handlers={{ escape: () => this.close() }} />
</section>
</div>
</div>
Expand Down
93 changes: 93 additions & 0 deletions client/modules/IDE/components/IDEKeyHandlers.jsx
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions client/modules/IDE/components/Modal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -30,6 +31,8 @@ const Modal = ({
};
}, []);

useKeyDownHandlers({ escape: onClose });

return (
<section className="modal" ref={modalRef}>
<div className={classNames('modal-content', contentClassName)}>
Expand Down
60 changes: 60 additions & 0 deletions client/modules/IDE/hooks/useKeyDownHandlers.js
Original file line number Diff line number Diff line change
@@ -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<string, (e: KeyboardEvent) => 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<string, (e: KeyboardEvent) => void>} handlers
*/
export const DocumentKeyDown = ({ handlers }) => {
useKeyDownHandlers(handlers);
return null;
};
DocumentKeyDown.propTypes = {
handlers: PropTypes.objectOf(PropTypes.func)
};
Loading
Loading