feat: use local HotKeys and allow editor escape

GlobalHotKeys were abandoned, because they were capturing events that
had called stopPropagation.  This meant that something needed to be in
focus, hence passing a ref of the HotKey DOM element to the Editor.

react-hotkeys has been updated to 2.0.0-pre9, because 2.0.0 captured
ctrl keypresses when asked for ctrl+enter

Currently only classic challenges can be executed by hotkey, but all
allow hotkey navigation
This commit is contained in:
Oliver Eyton-Williams
2019-09-18 17:46:19 +02:00
committed by mrugesh
parent ab3a9076d9
commit c91393d737
8 changed files with 128 additions and 83 deletions

View File

@ -16613,9 +16613,9 @@
}
},
"react-hotkeys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz",
"integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==",
"version": "2.0.0-pre9",
"resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0-pre9.tgz",
"integrity": "sha512-YujzB+kGB5F6rq6/NkNN2t3uSwYfBsC9qWligGKyDe7roMSmzFYO2N88mwSc+9zmHhy/ZrDyB+aqbzVIaK8haw==",
"requires": {
"prop-types": "^15.6.1"
},

View File

@ -53,7 +53,7 @@
"react-final-form": "^6.3.0",
"react-ga": "^2.6.0",
"react-helmet": "^5.2.1",
"react-hotkeys": "^2.0.0",
"react-hotkeys": "^2.0.0-pre9",
"react-identicons": "^1.1.7",
"react-instantsearch-dom": "^5.7.0",
"react-monaco-editor": "^0.30.1",

View File

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { navigate } from 'gatsby';
import { executeChallenge, updateFile } from '../redux';
import { userSelector, isDonationModalOpenSelector } from '../../../redux';
@ -13,13 +12,12 @@ const MonacoEditor = React.lazy(() => import('react-monaco-editor'));
const propTypes = {
canFocus: PropTypes.bool,
containerRef: PropTypes.any.isRequired,
contents: PropTypes.string,
dimensions: PropTypes.object,
executeChallenge: PropTypes.func.isRequired,
ext: PropTypes.string,
fileKey: PropTypes.string,
nextChallengePath: PropTypes.string.isRequired,
prevChallengePath: PropTypes.string.isRequired,
theme: PropTypes.string,
updateFile: PropTypes.func.isRequired
};
@ -123,26 +121,13 @@ class Editor extends Component {
run: this.props.executeChallenge
});
this._editor.addAction({
id: 'navigate-prev',
label: 'Navigate to previous challenge',
keybindings: [
/* eslint-disable no-bitwise */
monaco.KeyMod.chord(
monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.US_COMMA
)
],
run: () => navigate(this.props.prevChallengePath)
});
this._editor.addAction({
id: 'navigate-next',
label: 'Navigate to next challenge',
keybindings: [
/* eslint-disable no-bitwise */
monaco.KeyMod.chord(
monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.US_DOT
)
],
run: () => navigate(this.props.nextChallengePath)
id: 'leave-editor',
label: 'Leave editor',
keybindings: [monaco.KeyCode.Escape],
run: () => {
if (this.props.containerRef.current)
this.props.containerRef.current.focus();
}
});
};

View File

@ -19,6 +19,7 @@ import VideoModal from '../components/VideoModal';
import ResetModal from '../components/ResetModal';
import MobileLayout from './MobileLayout';
import DesktopLayout from './DesktopLayout';
import Hotkeys from '../components/Hotkeys';
import { getGuideUrl } from '../utils';
import { challengeTypes } from '../../../../utils/challengeTypes';
@ -32,7 +33,8 @@ import {
initTests,
updateChallengeMeta,
challengeMounted,
consoleOutputSelector
consoleOutputSelector,
executeChallenge
} from '../redux';
import './classic.css';
@ -51,7 +53,8 @@ const mapDispatchToProps = dispatch =>
initConsole,
initTests,
updateChallengeMeta,
challengeMounted
challengeMounted,
executeChallenge
},
dispatch
);
@ -62,6 +65,7 @@ const propTypes = {
data: PropTypes.shape({
challengeNode: ChallengeNode
}),
executeChallenge: PropTypes.func.isRequired,
files: PropTypes.shape({
key: PropTypes.string
}),
@ -99,6 +103,8 @@ class ShowClassic extends Component {
this.state = {
resizing: false
};
this.containerRef = React.createRef();
}
onResize() {
this.setState({ resizing: true });
@ -219,19 +225,13 @@ class ShowClassic extends Component {
}
renderEditor() {
const {
files,
pageContext: {
challengeMeta: { prevChallengePath, nextChallengePath }
}
} = this.props;
const { files } = this.props;
const challengeFile = first(Object.keys(files).map(key => files[key]));
return (
challengeFile && (
<Editor
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
containerRef={this.containerRef}
{...challengeFile}
fileKey={challengeFile.key}
/>
@ -261,42 +261,56 @@ class ShowClassic extends Component {
render() {
const { forumTopicId, title } = this.getChallenge();
const {
executeChallenge,
pageContext: {
challengeMeta: { introPath, nextChallengePath, prevChallengePath }
}
} = this.props;
return (
<LearnLayout>
<Helmet
title={`Learn ${this.getBlockNameTitle()} | freeCodeCamp.org`}
/>
<Media maxWidth={MAX_MOBILE_WIDTH}>
<MobileLayout
editor={this.renderEditor()}
guideUrl={getGuideUrl({ forumTopicId, title })}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: false
})}
preview={this.renderPreview()}
testOutput={this.renderTestOutput()}
videoUrl={this.getVideoUrl()}
<Hotkeys
executeChallenge={executeChallenge}
innerRef={this.containerRef}
introPath={introPath}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
>
<LearnLayout>
<Helmet
title={`Learn ${this.getBlockNameTitle()} | freeCodeCamp.org`}
/>
</Media>
<Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout
challengeFile={this.getChallengeFile()}
editor={this.renderEditor()}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: true
})}
preview={this.renderPreview()}
resizeProps={this.resizeProps}
testOutput={this.renderTestOutput()}
/>
</Media>
<CompletionModal />
<HelpModal />
<VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal />
</LearnLayout>
<Media maxWidth={MAX_MOBILE_WIDTH}>
<MobileLayout
editor={this.renderEditor()}
guideUrl={getGuideUrl({ forumTopicId, title })}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: false
})}
preview={this.renderPreview()}
testOutput={this.renderTestOutput()}
videoUrl={this.getVideoUrl()}
/>
</Media>
<Media minWidth={MAX_MOBILE_WIDTH + 1}>
<DesktopLayout
challengeFile={this.getChallengeFile()}
editor={this.renderEditor()}
hasPreview={this.hasPreview()}
instructions={this.renderInstructionsPanel({
showToolPanel: true
})}
preview={this.renderPreview()}
resizeProps={this.resizeProps}
testOutput={this.renderTestOutput()}
/>
</Media>
<CompletionModal />
<HelpModal />
<VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal />
</LearnLayout>
</Hotkeys>
);
}
}

View File

@ -1,17 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import Link from '../../../components/helpers/Link';
import { GlobalHotKeys } from 'react-hotkeys';
import { navigate } from 'gatsby';
import './challenge-title.css';
import GreenPass from '../../../assets/icons/GreenPass';
const keyMap = {
NAVIGATE_PREV: ['ctrl+shift+<', 'cmd+shift+<'],
NAVIGATE_NEXT: ['ctrl+shift+>', 'cmd+shift+>']
};
const propTypes = {
children: PropTypes.string,
introPath: PropTypes.string,
@ -29,13 +22,8 @@ function ChallengeTitle({
prevChallengePath,
showPrevNextBtns
}) {
const handlers = {
NAVIGATE_PREV: () => navigate(prevChallengePath),
NAVIGATE_NEXT: () => navigate(nextChallengePath)
};
return (
<div className='challenge-title-wrap'>
<GlobalHotKeys handlers={handlers} keyMap={keyMap} />
{showPrevNextBtns ? (
<Link
aria-label='Previous lesson'

View File

@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { HotKeys } from 'react-hotkeys';
import { navigate } from 'gatsby';
const keyMap = {
EXECUTE_CHALLENGE: 'ctrl+enter',
NAVIGATE_PREV: ['ctrl+left', 'cmd+left'],
NAVIGATE_NEXT: ['ctrl+right', 'cmd+right']
};
const propTypes = {
children: PropTypes.any,
executeChallenge: PropTypes.func,
innerRef: PropTypes.any,
introPath: PropTypes.string,
nextChallengePath: PropTypes.string,
prevChallengePath: PropTypes.string
};
function Hotkeys({
children,
executeChallenge,
introPath,
innerRef,
nextChallengePath,
prevChallengePath
}) {
const handlers = {
EXECUTE_CHALLENGE: () => {
if (executeChallenge) executeChallenge();
},
NAVIGATE_PREV: () => navigate(prevChallengePath),
NAVIGATE_NEXT: () => navigate(introPath ? introPath : nextChallengePath)
};
return (
<HotKeys handlers={handlers} innerRef={innerRef} keyMap={keyMap}>
{children}
</HotKeys>
);
}
Hotkeys.displayName = 'Hotkeys';
Hotkeys.propTypes = propTypes;
export default Hotkeys;

View File

@ -33,6 +33,7 @@ import { Form } from '../../../../components/formHelpers';
import Spacer from '../../../../components/helpers/Spacer';
import { ChallengeNode } from '../../../../redux/propTypes';
import { isSignedInSelector } from '../../../../redux';
import Hotkeys from '../../components/Hotkeys';
import { backend } from '../../../../../utils/challengeTypes';
@ -186,6 +187,11 @@ export class BackEnd extends Component {
return (
<LearnLayout>
<Helmet title={`${blockNameTitle} | Learn | freeCodeCamp.org`} />
<Hotkeys
introPath={introPath}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
/>
<Grid>
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>

View File

@ -24,6 +24,7 @@ import ProjectForm from '../ProjectForm';
import ProjectToolPanel from '../Tool-Panel';
import CompletionModal from '../../components/CompletionModal';
import HelpModal from '../../components/HelpModal';
import Hotkeys from '../../components/Hotkeys';
const mapStateToProps = () => ({});
const mapDispatchToProps = dispatch =>
@ -110,6 +111,11 @@ export class Project extends Component {
const blockNameTitle = `${blockName} - ${title}`;
return (
<LearnLayout>
<Hotkeys
introPath={introPath}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
/>
<Helmet title={`${blockNameTitle} | Learn | freeCodeCamp.org`} />
<Grid>
<Row>