feat: multiple concurrent editors

This commit is contained in:
Oliver Eyton-Williams
2020-07-27 15:20:19 +02:00
committed by Mrugesh Mohapatra
parent 2e7a2424c1
commit 02aff4d400
4 changed files with 440 additions and 191 deletions

View File

@ -1,4 +1,4 @@
import React, { Component, Suspense } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
@ -16,8 +16,6 @@ import {
updateFile updateFile
} from '../redux'; } from '../redux';
import { userSelector, isDonationModalOpenSelector } from '../../../redux'; import { userSelector, isDonationModalOpenSelector } from '../../../redux';
import { Loader } from '../../../components/helpers';
import { toSortedArray } from '../../../../../utils/sort-files';
import './editor.css'; import './editor.css';
@ -25,6 +23,7 @@ const MonacoEditor = Loadable(() => import('react-monaco-editor'));
const propTypes = { const propTypes = {
canFocus: PropTypes.bool, canFocus: PropTypes.bool,
// TODO: use shape
challengeFiles: PropTypes.object, challengeFiles: PropTypes.object,
containerRef: PropTypes.any.isRequired, containerRef: PropTypes.any.isRequired,
contents: PropTypes.string, contents: PropTypes.string,
@ -37,6 +36,10 @@ const propTypes = {
initialEditorContent: PropTypes.string, initialEditorContent: PropTypes.string,
initialExt: PropTypes.string, initialExt: PropTypes.string,
output: PropTypes.arrayOf(PropTypes.string), output: PropTypes.arrayOf(PropTypes.string),
resizeProps: PropTypes.shape({
onStopResize: PropTypes.func,
onResize: PropTypes.func
}),
saveEditorContent: PropTypes.func.isRequired, saveEditorContent: PropTypes.func.isRequired,
setAccessibilityMode: PropTypes.func.isRequired, setAccessibilityMode: PropTypes.func.isRequired,
setEditorFocusability: PropTypes.func, setEditorFocusability: PropTypes.func,
@ -118,9 +121,6 @@ class Editor extends Component {
// available files into that order. i.e. if it's just one file it will // available files into that order. i.e. if it's just one file it will
// automatically be first, but if there's jsx and js (for some reason) it // automatically be first, but if there's jsx and js (for some reason) it
// will be [jsx, js]. // will be [jsx, js].
// this.state = {
// fileKey: 'indexhtml'
// };
// NOTE: This looks like it should be react state. However we need // NOTE: This looks like it should be react state. However we need
// to access monaco.editor to create the models and store the state and that // to access monaco.editor to create the models and store the state and that
@ -129,52 +129,22 @@ class Editor extends Component {
// As a result it was unclear how to link up the editor's lifecycle with // As a result it was unclear how to link up the editor's lifecycle with
// react's lifecycle. Simply storing the models and state here and letting // react's lifecycle. Simply storing the models and state here and letting
// the editor control them seems to be the best solution. // the editor control them seems to be the best solution.
// TODO: IS THIS STILL TRUE NOW EACH EDITOR IS AN ISLAND, ENTIRE OF ITSELF?
// TODO: is there any point in initializing this? It should be fine with // TODO: is there any point in initializing this? It should be fine with
// this.data = {indexjs:{}, indexcss:{}, indexhtml:{}, indexjsx: {}} // this.data = {}
this.data = { this.data = {
indexjs: { model: null,
model: null, state: null,
state: null, viewZoneId: null,
viewZoneId: null, startEditDecId: null,
startEditDecId: null, endEditDecId: null,
endEditDecId: null, viewZoneHeight: null
viewZoneHeight: null
},
indexcss: {
model: null,
state: null,
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
viewZoneHeight: null
},
indexhtml: {
model: null,
state: null,
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
viewZoneHeight: null
},
indexjsx: {
model: null,
state: null,
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
viewZoneHeight: null
}
}; };
const { challengeFiles } = this.props;
// NOTE: the ARIA state is controlled by fileKey, so changes to it must // NOTE: the ARIA state is controlled by fileKey, so changes to it must
// trigger a re-render. Hence state: // trigger a re-render. Hence state:
this.state = {
fileKey: toSortedArray(challengeFiles)[0].key
};
this.options = { this.options = {
fontSize: '18px', fontSize: '18px',
@ -214,53 +184,54 @@ class Editor extends Component {
editorWillMount = monaco => { editorWillMount = monaco => {
this._monaco = monaco; this._monaco = monaco;
const { challengeFiles } = this.props; const { challengeFiles, fileKey } = this.props;
defineMonacoThemes(monaco); defineMonacoThemes(monaco);
// If a model is not provided, then the editor 'owns' the model it creates // If a model is not provided, then the editor 'owns' the model it creates
// and will dispose of that model if it is replaced. Since we intend to // and will dispose of that model if it is replaced. Since we intend to
// swap and reuse models, we have to create our own models to prevent // swap and reuse models, we have to create our own models to prevent
// disposal. // disposal.
Object.keys(challengeFiles).forEach(key => { // TODO: For now, I'm keeping the 'data' machinery, but it'll probably go
// If a model exists, there is no need to recreate it.
const model =
this.data[key].model ||
monaco.editor.createModel(
challengeFiles[key].contents,
modeMap[challengeFiles[key].ext]
);
this.data[key].model = model;
const editableRegion = [...challengeFiles[key].editableRegionBoundaries]; const model =
this.data.model ||
monaco.editor.createModel(
challengeFiles[fileKey].contents,
modeMap[challengeFiles[fileKey].ext]
);
this.data.model = model;
if (editableRegion.length === 2) const editableRegion = [
this.decorateForbiddenRanges(key, editableRegion); ...challengeFiles[fileKey].editableRegionBoundaries
}); ];
return { model: this.data[this.state.fileKey].model };
if (editableRegion.length === 2)
this.decorateForbiddenRanges(editableRegion);
return { model: this.data.model };
}; };
// Updates the model if the contents has changed. This is only necessary for // Updates the model if the contents has changed. This is only necessary for
// changes coming from outside the editor (such as code resets). // changes coming from outside the editor (such as code resets).
updateEditorValues = () => { updateEditorValues = () => {
const { challengeFiles } = this.props; const { challengeFiles, fileKey } = this.props;
Object.keys(challengeFiles).forEach(key => {
const newContents = challengeFiles[key].contents; const newContents = challengeFiles[fileKey].contents;
if (this.data[key].model.getValue() !== newContents) { if (this.data.model.getValue() !== newContents) {
this.data[key].model.setValue(newContents); this.data.model.setValue(newContents);
} }
});
}; };
editorDidMount = (editor, monaco) => { editorDidMount = (editor, monaco) => {
this._editor = editor; this._editor = editor;
const { challengeFiles } = this.props; const { challengeFiles, fileKey } = this.props;
const { fileKey } = this.state;
editor.updateOptions({ editor.updateOptions({
accessibilitySupport: this.props.inAccessibilityMode ? 'on' : 'auto' accessibilitySupport: this.props.inAccessibilityMode ? 'on' : 'auto'
}); });
// Users who are using screen readers should not have to move focus from // Users who are using screen readers should not have to move focus from
// the editor to the description every time they open a challenge. // the editor to the description every time they open a challenge.
if (this.props.canFocus && !this.props.inAccessibilityMode) { if (this.props.canFocus && !this.props.inAccessibilityMode) {
// TODO: only one Editor should be calling for focus at once.
editor.focus(); editor.focus();
} else this.focusOnHotkeys(); } else this.focusOnHotkeys();
editor.addAction({ editor.addAction({
@ -382,7 +353,6 @@ class Editor extends Component {
}; };
viewZoneCallback = changeAccessor => { viewZoneCallback = changeAccessor => {
const { fileKey } = this.state;
// TODO: is there any point creating this here? I know it's cached, but // TODO: is there any point creating this here? I know it's cached, but
// would it not be better just sourced from the overlayWidget? // would it not be better just sourced from the overlayWidget?
const domNode = this.createDescription(); const domNode = this.createDescription();
@ -391,7 +361,7 @@ class Editor extends Component {
domNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px'; domNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px';
// TODO: set via onComputedHeight? // TODO: set via onComputedHeight?
this.data[fileKey].viewZoneHeight = domNode.offsetHeight; this.data.viewZoneHeight = domNode.offsetHeight;
var background = document.createElement('div'); var background = document.createElement('div');
background.style.background = 'lightgreen'; background.style.background = 'lightgreen';
@ -407,12 +377,11 @@ class Editor extends Component {
this._editor.layoutOverlayWidget(this._overlayWidget) this._editor.layoutOverlayWidget(this._overlayWidget)
}; };
this.data[fileKey].viewZoneId = changeAccessor.addZone(viewZone); this.data.viewZoneId = changeAccessor.addZone(viewZone);
}; };
// TODO: this is basically the same as viewZoneCallback, so DRY them out. // TODO: this is basically the same as viewZoneCallback, so DRY them out.
outputZoneCallback = changeAccessor => { outputZoneCallback = changeAccessor => {
const { fileKey } = this.state;
// TODO: is there any point creating this here? I know it's cached, but // TODO: is there any point creating this here? I know it's cached, but
// would it not be better just sourced from the overlayWidget? // would it not be better just sourced from the overlayWidget?
const outputNode = this.createOutputNode(); const outputNode = this.createOutputNode();
@ -421,7 +390,7 @@ class Editor extends Component {
outputNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px'; outputNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px';
// TODO: set via onComputedHeight? // TODO: set via onComputedHeight?
this.data[fileKey].outputZoneHeight = outputNode.offsetHeight; this.data.outputZoneHeight = outputNode.offsetHeight;
var background = document.createElement('div'); var background = document.createElement('div');
background.style.background = 'lightpink'; background.style.background = 'lightpink';
@ -437,7 +406,7 @@ class Editor extends Component {
this._editor.layoutOverlayWidget(this._outputWidget) this._editor.layoutOverlayWidget(this._outputWidget)
}; };
this.data[fileKey].outputZoneId = changeAccessor.addZone(viewZone); this.data.outputZoneId = changeAccessor.addZone(viewZone);
}; };
createDescription() { createDescription() {
@ -516,7 +485,7 @@ class Editor extends Component {
onChange = editorValue => { onChange = editorValue => {
const { updateFile } = this.props; const { updateFile } = this.props;
// TODO: use fileKey everywhere? // TODO: use fileKey everywhere?
const { fileKey: key } = this.state; const { fileKey: key } = this.props;
// TODO: now that we have getCurrentEditableRegion, should the overlays // TODO: now that we have getCurrentEditableRegion, should the overlays
// follow that directly? We could subscribe to changes to that and redraw if // follow that directly? We could subscribe to changes to that and redraw if
// those imply that the positions have changed (i.e. if the content height // those imply that the positions have changed (i.e. if the content height
@ -530,28 +499,6 @@ class Editor extends Component {
updateFile({ key, editorValue, editableRegionBoundaries }); updateFile({ key, editorValue, editableRegionBoundaries });
}; };
changeTab = newFileKey => {
const { challengeFiles } = this.props;
this.setState({ fileKey: newFileKey });
const editor = this._editor;
const currentState = editor.saveViewState();
const currentModel = editor.getModel();
for (const key in this.data) {
if (currentModel === this.data[key].model) {
this.data[key].state = currentState;
}
}
editor.setModel(this.data[newFileKey].model);
editor.restoreViewState(this.data[newFileKey].state);
editor.focus();
const editableBoundaries = [
...challengeFiles[newFileKey].editableRegionBoundaries
];
this.showEditableRegion(editableBoundaries);
};
showEditableRegion(editableBoundaries) { showEditableRegion(editableBoundaries) {
if (editableBoundaries.length !== 2) return; if (editableBoundaries.length !== 2) return;
// this is a heuristic: if the cursor is at the start of the page, chances // this is a heuristic: if the cursor is at the start of the page, chances
@ -597,7 +544,7 @@ class Editor extends Component {
// currently is. (see getLineAfterViewZone) // currently is. (see getLineAfterViewZone)
// TODO: DRY this and getOutputZoneTop out. // TODO: DRY this and getOutputZoneTop out.
getViewZoneTop() { getViewZoneTop() {
const heightDelta = this.data[this.state.fileKey].viewZoneHeight || 0; const heightDelta = this.data.viewZoneHeight || 0;
const top = const top =
this._editor.getTopForLineNumber(this.getLineAfterViewZone()) - this._editor.getTopForLineNumber(this.getLineAfterViewZone()) -
@ -609,7 +556,7 @@ class Editor extends Component {
} }
getOutputZoneTop() { getOutputZoneTop() {
const heightDelta = this.data[this.state.fileKey].outputZoneHeight || 0; const heightDelta = this.data.outputZoneHeight || 0;
const top = const top =
this._editor.getTopForLineNumber(this.getLineAfterEditableRegion()) - this._editor.getTopForLineNumber(this.getLineAfterEditableRegion()) -
@ -624,19 +571,15 @@ class Editor extends Component {
// the region it should cover instead. // the region it should cover instead.
// TODO: DRY // TODO: DRY
getLineAfterViewZone() { getLineAfterViewZone() {
const { fileKey } = this.state;
return ( return (
this.data[fileKey].model.getDecorationRange( this.data.model.getDecorationRange(this.data.startEditDecId)
this.data[fileKey].startEditDecId .endLineNumber + 1
).endLineNumber + 1
); );
} }
getLineAfterEditableRegion() { getLineAfterEditableRegion() {
const { fileKey } = this.state; return this.data.model.getDecorationRange(this.data.endEditDecId)
return this.data[fileKey].model.getDecorationRange( .startLineNumber;
this.data[fileKey].endEditDecId
).startLineNumber;
} }
translateRange = (range, lineDelta) => { translateRange = (range, lineDelta) => {
@ -661,14 +604,13 @@ class Editor extends Component {
}; };
}; };
getCurrentEditableRegion = key => { getCurrentEditableRegion = () => {
const model = this.data[key].model; const model = this.data.model;
// TODO: this is a little low-level, but we should bail if there is no // TODO: this is a little low-level, but we should bail if there is no
// editable region defined. // editable region defined.
if (!this.data[key].startEditDecId || !this.data[key].endEditDecId) if (!this.data.startEditDecId || !this.data.endEditDecId) return null;
return null; const firstRange = model.getDecorationRange(this.data.startEditDecId);
const firstRange = model.getDecorationRange(this.data[key].startEditDecId); const secondRange = model.getDecorationRange(this.data.endEditDecId);
const secondRange = model.getDecorationRange(this.data[key].endEditDecId);
const { startLineNumber, endLineNumber } = this.getLinesBetweenRanges( const { startLineNumber, endLineNumber } = this.getLinesBetweenRanges(
firstRange, firstRange,
secondRange secondRange
@ -681,8 +623,8 @@ class Editor extends Component {
return new this._monaco.Range(startLineNumber, 1, endLineNumber, endColumn); return new this._monaco.Range(startLineNumber, 1, endLineNumber, endColumn);
}; };
decorateForbiddenRanges(key, editableRegion) { decorateForbiddenRanges(editableRegion) {
const model = this.data[key].model; const model = this.data.model;
const forbiddenRanges = [ const forbiddenRanges = [
[1, editableRegion[0]], [1, editableRegion[0]],
[editableRegion[1], model.getLineCount()] [editableRegion[1], model.getLineCount()]
@ -693,7 +635,7 @@ class Editor extends Component {
}); });
// the first range should expand at the top // the first range should expand at the top
this.data[key].startEditDecId = this.highlightLines( this.data.startEditDecId = this.highlightLines(
this._monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore, this._monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
model, model,
ranges[0] ranges[0]
@ -706,7 +648,7 @@ class Editor extends Component {
); );
// the second range should expand at the bottom // the second range should expand at the bottom
this.data[key].endEditDecId = this.highlightLines( this.data.endEditDecId = this.highlightLines(
this._monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingAfter, this._monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingAfter,
model, model,
ranges[1] ranges[1]
@ -871,15 +813,15 @@ class Editor extends Component {
// we only need to handle the special case of the second region being // we only need to handle the special case of the second region being
// pulled up, the first region already behaves correctly. // pulled up, the first region already behaves correctly.
this.data[key].endEditDecId = preventOverlap(this.data[key].endEditDecId); this.data.endEditDecId = preventOverlap(this.data.endEditDecId);
// TODO: do the same for the description widget // TODO: do the same for the description widget
// this has to be handle differently, because we care about the END // this has to be handle differently, because we care about the END
// of the zone, not the START // of the zone, not the START
handleDescriptionZoneChange(this.data[key].startEditDecId); handleDescriptionZoneChange(this.data.startEditDecId);
handleHintsZoneChange(this.data[key].endEditDecId); handleHintsZoneChange(this.data.endEditDecId);
warnUser(this.data[key].startEditDecId); warnUser(this.data.startEditDecId);
warnUser(this.data[key].endEditDecId); warnUser(this.data.endEditDecId);
}); });
} }
@ -908,7 +850,7 @@ class Editor extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
// TODO: re-organise into something less nested // TODO: re-organise into something less nested
const { fileKey } = this.state;
// If a challenge is reset, it needs to communicate that change to the // If a challenge is reset, it needs to communicate that change to the
// editor. This looks for changes any challenge files and updates if needed. // editor. This looks for changes any challenge files and updates if needed.
if (this.props.challengeFiles !== prevProps.challengeFiles) { if (this.props.challengeFiles !== prevProps.challengeFiles) {
@ -924,7 +866,6 @@ class Editor extends Component {
// (shownHint,maybe) and have that persist through previews. But, for // (shownHint,maybe) and have that persist through previews. But, for
// now: // now:
if (output) { if (output) {
console.log('OUTPUT', output);
if (output[0]) { if (output[0]) {
document.getElementById('test-status').innerHTML = output[0]; document.getElementById('test-status').innerHTML = output[0];
} }
@ -933,17 +874,19 @@ class Editor extends Component {
document.getElementById('test-output').innerHTML = output[1]; document.getElementById('test-output').innerHTML = output[1];
} }
if (this.data[fileKey].startEditDecId) { if (this.data.startEditDecId) {
this.updateOutputZone(); this.updateOutputZone();
} }
} }
} }
// TODO: to get the 'dimensions' prop, this needs to be a inside a
// ReflexElement
if (this.props.dimensions !== prevProps.dimensions) { if (this.props.dimensions !== prevProps.dimensions) {
// Order matters here. The view zones need to know the new editor // Order matters here. The view zones need to know the new editor
// dimensions in order to render correctly. // dimensions in order to render correctly.
this._editor.layout(); this._editor.layout();
if (this.data[fileKey].startEditDecId) { if (this.data.startEditDecId) {
this.updateViewZone(); this.updateViewZone();
this.updateOutputZone(); this.updateOutputZone();
} }
@ -954,86 +897,34 @@ class Editor extends Component {
// TODO: DRY (there's going to be a lot of that) // TODO: DRY (there's going to be a lot of that)
updateOutputZone() { updateOutputZone() {
this._editor.changeViewZones(changeAccessor => { this._editor.changeViewZones(changeAccessor => {
changeAccessor.removeZone(this.data[this.state.fileKey].outputZoneId); changeAccessor.removeZone(this.data.outputZoneId);
this.outputZoneCallback(changeAccessor); this.outputZoneCallback(changeAccessor);
}); });
} }
updateViewZone() { updateViewZone() {
this._editor.changeViewZones(changeAccessor => { this._editor.changeViewZones(changeAccessor => {
changeAccessor.removeZone(this.data[this.state.fileKey].viewZoneId); changeAccessor.removeZone(this.data.viewZoneId);
this.viewZoneCallback(changeAccessor); this.viewZoneCallback(changeAccessor);
}); });
} }
componentWillUnmount() { componentWillUnmount() {
this.setState({ fileKey: null });
this.data = null; this.data = null;
} }
render() { render() {
const { challengeFiles, theme } = this.props; const { theme } = this.props;
const editorTheme = theme === 'night' ? 'vs-dark-custom' : 'vs-custom'; const editorTheme = theme === 'night' ? 'vs-dark-custom' : 'vs-custom';
// TODO: tabs should be dynamically created from the challengeFiles
// TODO: is the key necessary? Try switching themes without it.
// TODO: the tabs mess up the rendering (scroll doesn't work properly and
// the in-editor description)
return ( return (
<Suspense fallback={<Loader timeout={600} />}> <MonacoEditor
<span className='notranslate'> editorDidMount={this.editorDidMount}
<div className='monaco-editor-tabs'> editorWillMount={this.editorWillMount}
{challengeFiles['indexjsx'] && ( onChange={this.onChange}
<button options={this.options}
aria-selected={this.state.fileKey === 'indexjsx'} theme={editorTheme}
className='monaco-editor-tab' />
onClick={() => this.changeTab('indexjsx')}
role='tab'
>
script.jsx
</button>
)}
{challengeFiles['indexhtml'] && (
<button
aria-selected={this.state.fileKey === 'indexhtml'}
className='monaco-editor-tab'
onClick={() => this.changeTab('indexhtml')}
role='tab'
>
index.html
</button>
)}
{challengeFiles['indexjs'] && (
<button
aria-selected={this.state.fileKey === 'indexjs'}
className='monaco-editor-tab'
onClick={() => this.changeTab('indexjs')}
role='tab'
>
script.js
</button>
)}
{challengeFiles['indexcss'] && (
<button
aria-selected={this.state.fileKey === 'indexcss'}
className='monaco-editor-tab'
onClick={() => this.changeTab('indexcss')}
role='tab'
>
styles.css
</button>
)}
</div>
<MonacoEditor
editorDidMount={this.editorDidMount}
editorWillMount={this.editorWillMount}
key={`${editorTheme}`}
onChange={this.onChange}
options={this.options}
theme={editorTheme}
/>
</span>
</Suspense>
); );
} }
} }

View File

@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
challengeFiles: PropTypes.object.isRequired,
toggleTab: PropTypes.func.isRequired,
visibleEditors: PropTypes.shape({
indexjs: PropTypes.bool,
indexjsx: PropTypes.bool,
indexcss: PropTypes.bool,
indexhtml: PropTypes.bool
})
};
const EditorTabs = ({ challengeFiles, toggleTab, visibleEditors }) => (
<div className='monaco-editor-tabs'>
{challengeFiles['indexjsx'] && (
<button
aria-selected={visibleEditors.indexjsx}
className='monaco-editor-tab'
onClick={() => toggleTab('indexjsx')}
role='tab'
>
script.jsx
</button>
)}
{challengeFiles['indexhtml'] && (
<button
aria-selected={visibleEditors.indexhtml}
className='monaco-editor-tab'
onClick={() => toggleTab('indexhtml')}
role='tab'
>
index.html
</button>
)}
{challengeFiles['indexcss'] && (
<button
aria-selected={visibleEditors.indexcss}
className='monaco-editor-tab'
onClick={() => toggleTab('indexcss')}
role='tab'
>
styles.css
</button>
)}
{challengeFiles['indexjs'] && (
<button
aria-selected={visibleEditors.indexjs}
className='monaco-editor-tab'
onClick={() => toggleTab('indexjs')}
role='tab'
>
script.js
</button>
)}
</div>
);
EditorTabs.displayName = 'EditorTabs';
EditorTabs.propTypes = propTypes;
export default EditorTabs;

View File

@ -0,0 +1,294 @@
import PropTypes from 'prop-types';
import React, { Component, Suspense } from 'react';
import { connect } from 'react-redux';
import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex';
import { createSelector } from 'reselect';
import { isDonationModalOpenSelector, userSelector } from '../../../redux';
import {
canFocusEditorSelector,
consoleOutputSelector,
executeChallenge,
inAccessibilityModeSelector,
saveEditorContent,
setAccessibilityMode,
setEditorFocusability,
updateFile
} from '../redux';
import './editor.css';
import { Loader } from '../../../components/helpers';
import EditorTabs from './EditorTabs';
import Editor from './Editor';
import { toSortedArray } from '../../../../../utils/sort-files';
const propTypes = {
canFocus: PropTypes.bool,
// TODO: use shape
challengeFiles: PropTypes.object,
containerRef: PropTypes.any.isRequired,
contents: PropTypes.string,
description: PropTypes.string,
dimensions: PropTypes.object,
editorRef: PropTypes.any.isRequired,
executeChallenge: PropTypes.func.isRequired,
ext: PropTypes.string,
fileKey: PropTypes.string,
inAccessibilityMode: PropTypes.bool.isRequired,
initialEditorContent: PropTypes.string,
initialExt: PropTypes.string,
output: PropTypes.arrayOf(PropTypes.string),
resizeProps: PropTypes.shape({
onStopResize: PropTypes.func,
onResize: PropTypes.func
}),
saveEditorContent: PropTypes.func.isRequired,
setAccessibilityMode: PropTypes.func.isRequired,
setEditorFocusability: PropTypes.func,
theme: PropTypes.string,
updateFile: PropTypes.func.isRequired
};
const mapStateToProps = createSelector(
canFocusEditorSelector,
consoleOutputSelector,
inAccessibilityModeSelector,
isDonationModalOpenSelector,
userSelector,
(canFocus, output, accessibilityMode, open, { theme = 'default' }) => ({
canFocus: open ? false : canFocus,
output,
inAccessibilityMode: accessibilityMode,
theme
})
);
const mapDispatchToProps = {
executeChallenge,
saveEditorContent,
setAccessibilityMode,
setEditorFocusability,
updateFile
};
class MultifileEditor extends Component {
constructor(...props) {
super(...props);
// TENATIVE PLAN: create a typical order [html/jsx, css, js], put the
// available files into that order. i.e. if it's just one file it will
// automatically be first, but if there's jsx and js (for some reason) it
// will be [jsx, js].
// this.state = {
// fileKey: 'indexhtml'
// };
// NOTE: This looks like it should be react state. However we need
// to access monaco.editor to create the models and store the state and that
// is only available in the react-monaco-editor component's lifecycle hooks
// and not react's lifecyle hooks.
// As a result it was unclear how to link up the editor's lifecycle with
// react's lifecycle. Simply storing the models and state here and letting
// the editor control them seems to be the best solution.
// TODO: is there any point in initializing this? It should be fine with
// this.data = {indexjs:{}, indexcss:{}, indexhtml:{}, indexjsx: {}}
this.data = {
indexjs: {
model: null,
state: null,
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
viewZoneHeight: null
},
indexcss: {
model: null,
state: null,
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
viewZoneHeight: null
},
indexhtml: {
model: null,
state: null,
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
viewZoneHeight: null
},
indexjsx: {
model: null,
state: null,
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
viewZoneHeight: null
}
};
const { challengeFiles } = this.props;
const targetEditor = toSortedArray(challengeFiles)[0].key;
this.state = {
visibleEditors: { [targetEditor]: true }
};
// TODO: we might want to store the current editor here
this.focusOnEditor = this.focusOnEditor.bind(this);
}
focusOnHotkeys() {
if (this.props.containerRef.current) {
this.props.containerRef.current.focus();
}
}
focusOnEditor() {
// TODO: it should focus one of the editors
// this._editor.focus();
}
toggleTab = newFileKey => {
this.setState(state => ({
visibleEditors: {
...state.visibleEditors,
[newFileKey]: !state.visibleEditors[newFileKey]
}
}));
};
componentWillUnmount() {
// this.setState({ fileKey: null });
this.data = null;
}
render() {
const {
challengeFiles,
containerRef,
description,
editorRef,
theme,
resizeProps
} = this.props;
const { visibleEditors } = this.state;
const editorTheme = theme === 'night' ? 'vs-dark-custom' : 'vs-custom';
// TODO: the tabs mess up the rendering (scroll doesn't work properly and
// the in-editor description)
// TODO: the splitters should appear between editors, so logically this
// would be best as
// editors.map(props => <EditorWrapper ...props>).join(<ReflexSplitter>)
// ...probably! As long as we can put keys in the right places.
const reflexProps = {
propagateDimensions: true,
renderOnResize: true,
renderOnResizeRate: 20
};
// TODO: this approach (||ing the visibleEditors) isn't great.
const splitCSS = visibleEditors.indexhtml && visibleEditors.indexcss;
const splitJS =
visibleEditors.indexcss ||
(visibleEditors.indexhtml && visibleEditors.indexjs);
// TODO: tabs should be dynamically created from the challengeFiles
// TODO: the tabs mess up the rendering (scroll doesn't work properly and
// the in-editor description)
return (
<ReflexContainer
orientation='horizontal'
{...reflexProps}
{...resizeProps}
>
<ReflexElement flex={0.1}>
<EditorTabs
challengeFiles={challengeFiles}
toggleTab={this.toggleTab.bind(this)}
visibleEditors={visibleEditors}
/>
</ReflexElement>
<ReflexElement flex={0.9} {...reflexProps} {...resizeProps}>
<ReflexContainer orientation='vertical'>
{visibleEditors.indexhtml && (
<ReflexElement {...reflexProps} {...resizeProps}>
<Suspense fallback={<Loader timeout={600} />}>
<span className='notranslate'>
<Editor
challengeFiles={challengeFiles}
containerRef={containerRef}
// TODO: only pass description to the editor with the
// editable region
description={description}
fileKey='indexhtml'
key='indexhtml'
ref={editorRef}
resizeProps={resizeProps}
theme={editorTheme}
/>
</span>
</Suspense>
</ReflexElement>
)}
{splitCSS && <ReflexSplitter propagate={true} {...resizeProps} />}
{visibleEditors.indexcss && (
<ReflexElement {...reflexProps} {...resizeProps}>
<Suspense fallback={<Loader timeout={600} />}>
<span className='notranslate'>
<Editor
challengeFiles={challengeFiles}
containerRef={containerRef}
// TODO: only pass description to the editor with the
// editable region
description={'this sentence is not here'}
fileKey='indexcss'
key='indexcss'
resizeProps={resizeProps}
theme={editorTheme}
/>
</span>
</Suspense>
</ReflexElement>
)}
{splitJS && <ReflexSplitter propagate={true} {...resizeProps} />}
{visibleEditors.indexjs && (
<ReflexElement {...reflexProps} {...resizeProps}>
<Suspense fallback={<Loader timeout={600} />}>
<span className='notranslate'>
<Editor
challengeFiles={challengeFiles}
containerRef={containerRef}
// TODO: only pass description to the editor with the
// editable region
description={'neither is this one'}
fileKey='indexjs'
key='indexjs'
resizeProps={resizeProps}
theme={editorTheme}
/>
</span>
</Suspense>
</ReflexElement>
)}
</ReflexContainer>
</ReflexElement>
</ReflexContainer>
);
}
}
MultifileEditor.displayName = 'MultifileEditor';
MultifileEditor.propTypes = propTypes;
// NOTE: withRef gets replaced by forwardRef in react-redux 6,
// https://github.com/reduxjs/react-redux/releases/tag/v6.0.0
export default connect(
mapStateToProps,
mapDispatchToProps,
null,
{ withRef: true }
)(MultifileEditor);

View File

@ -9,7 +9,7 @@ import { first } from 'lodash';
import Media from 'react-responsive'; import Media from 'react-responsive';
import LearnLayout from '../../../components/layouts/Learn'; import LearnLayout from '../../../components/layouts/Learn';
import Editor from './Editor'; import MultifileEditor from './MultifileEditor';
import Preview from '../components/Preview'; import Preview from '../components/Preview';
import SidePanel from '../components/Side-Panel'; import SidePanel from '../components/Side-Panel';
import Output from '../components/Output'; import Output from '../components/Output';
@ -224,11 +224,12 @@ class ShowClassic extends Component {
const { description } = this.getChallenge(); const { description } = this.getChallenge();
return ( return (
files && ( files && (
<Editor <MultifileEditor
challengeFiles={files} challengeFiles={files}
containerRef={this.containerRef} containerRef={this.containerRef}
description={description} description={description}
ref={this.editorRef} editorRef={this.editorRef}
resizeProps={this.resizeProps}
/> />
) )
); );