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 3 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: 6 additions & 10 deletions client/components/Nav.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,30 +64,20 @@ 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({
dropdownOpen: dropdown
});
}

closeDropDown(e) {
if (e.keyCode === 27) {
this.setDropdown('none');
}
}

handleClick(e) {
if (!this.node) {
return;
Expand Down Expand Up @@ -914,6 +905,11 @@ class Nav extends React.PureComponent {
{this.renderLeftLayout(navDropdownState)}
{this.renderUserMenu(navDropdownState)}
</nav>
<DocumentKeyDown
handlers={{
escape: () => this.setDropdown('none')
}}
/>
</header>
);
}
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 { 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) {
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;
67 changes: 67 additions & 0 deletions client/modules/IDE/components/Modal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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

const Modal = ({
title,
onClose,
closeAriaLabel,
contentClassName,
children
}) => {
const modalRef = useRef(null);

const handleOutsideClick = (e) => {
// ignore clicks on the component itself
if (e.path.includes(modalRef.current)) return;

onClose();
};

useEffect(() => {
modalRef.current.focus();
document.addEventListener('click', handleOutsideClick, false);

return () => {
document.removeEventListener('click', handleOutsideClick, false);
};
}, []);

useKeyDownHandlers({ escape: onClose });

return (
<section className="modal" ref={modalRef}>
<div className={classNames('modal-content', contentClassName)}>
<div className="modal__header">
<h2 className="modal__title">{title}</h2>
<button
className="modal__exit-button"
onClick={onClose}
aria-label={closeAriaLabel}
>
<ExitIcon focusable="false" aria-hidden="true" />
</button>
</div>
{children}
</div>
</section>
);
};

Modal.propTypes = {
title: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
closeAriaLabel: PropTypes.string.isRequired,
contentClassName: PropTypes.string,
children: PropTypes.node.isRequired
};

Modal.defaultProps = {
contentClassName: ''
};

export default Modal;
93 changes: 16 additions & 77 deletions client/modules/IDE/components/NewFileModal.jsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,22 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { withTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import Modal from './Modal';
import NewFileForm from './NewFileForm';
import { closeNewFileModal } from '../actions/ide';
import ExitIcon from '../../../images/exit.svg';

// At some point this will probably be generalized to a generic modal
// in which you can insert different content
// but for now, let's just make this work
class NewFileModal extends React.Component {
constructor(props) {
super(props);
this.focusOnModal = this.focusOnModal.bind(this);
this.handleOutsideClick = this.handleOutsideClick.bind(this);
}

componentDidMount() {
this.focusOnModal();
document.addEventListener('click', this.handleOutsideClick, false);
}

componentWillUnmount() {
document.removeEventListener('click', this.handleOutsideClick, false);
}

handleOutsideClick(e) {
// ignore clicks on the component itself
if (e.path.includes(this.modal)) return;

this.props.closeNewFileModal();
}

focusOnModal() {
this.modal.focus();
}

render() {
return (
<section
className="modal"
ref={(element) => {
this.modal = element;
}}
>
<div className="modal-content">
<div className="modal__header">
<h2 className="modal__title">
{this.props.t('NewFileModal.Title')}
</h2>
<button
className="modal__exit-button"
onClick={this.props.closeNewFileModal}
aria-label={this.props.t('NewFileModal.CloseButtonARIA')}
>
<ExitIcon focusable="false" aria-hidden="true" />
</button>
</div>
<NewFileForm focusOnModal={this.focusOnModal} />
</div>
</section>
);
}
}

NewFileModal.propTypes = {
closeNewFileModal: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
const NewFileModal = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
return (
<Modal
title={t('NewFileModal.Title')}
closeAriaLabel={t('NewFileModal.CloseButtonARIA')}
onClose={() => dispatch(closeNewFileModal())}
>
<NewFileForm />
</Modal>
);
};

function mapStateToProps() {
return {};
}

function mapDispatchToProps(dispatch) {
return bindActionCreators({ closeNewFileModal }, dispatch);
}

export default withTranslation()(
connect(mapStateToProps, mapDispatchToProps)(NewFileModal)
);
export default NewFileModal;
Loading