feat: multiple concurrent editors
This commit is contained in:
committed by
Mrugesh Mohapatra
parent
2e7a2424c1
commit
02aff4d400
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
63
client/src/templates/Challenges/classic/EditorTabs.js
Normal file
63
client/src/templates/Challenges/classic/EditorTabs.js
Normal 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;
|
294
client/src/templates/Challenges/classic/MultifileEditor.js
Normal file
294
client/src/templates/Challenges/classic/MultifileEditor.js
Normal 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);
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
Reference in New Issue
Block a user