feat(multi): insert description into editor

This commit is contained in:
Oliver Eyton-Williams
2020-07-01 11:36:45 +02:00
committed by Mrugesh Mohapatra
parent 59c838e8ca
commit 1ee5e24d0f
2 changed files with 325 additions and 11 deletions

View File

@ -6,6 +6,7 @@ import isEqual from 'lodash/isEqual';
import { import {
canFocusEditorSelector, canFocusEditorSelector,
consoleOutputSelector,
executeChallenge, executeChallenge,
inAccessibilityModeSelector, inAccessibilityModeSelector,
saveEditorContent, saveEditorContent,
@ -26,6 +27,7 @@ const propTypes = {
challengeFiles: PropTypes.object, challengeFiles: PropTypes.object,
containerRef: PropTypes.any.isRequired, containerRef: PropTypes.any.isRequired,
contents: PropTypes.string, contents: PropTypes.string,
description: PropTypes.string,
dimensions: PropTypes.object, dimensions: PropTypes.object,
executeChallenge: PropTypes.func.isRequired, executeChallenge: PropTypes.func.isRequired,
ext: PropTypes.string, ext: PropTypes.string,
@ -33,6 +35,7 @@ const propTypes = {
inAccessibilityMode: PropTypes.bool.isRequired, inAccessibilityMode: PropTypes.bool.isRequired,
initialEditorContent: PropTypes.string, initialEditorContent: PropTypes.string,
initialExt: PropTypes.string, initialExt: PropTypes.string,
output: PropTypes.string,
saveEditorContent: PropTypes.func.isRequired, saveEditorContent: PropTypes.func.isRequired,
setAccessibilityMode: PropTypes.func.isRequired, setAccessibilityMode: PropTypes.func.isRequired,
setEditorFocusability: PropTypes.func, setEditorFocusability: PropTypes.func,
@ -42,11 +45,13 @@ const propTypes = {
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
canFocusEditorSelector, canFocusEditorSelector,
consoleOutputSelector,
inAccessibilityModeSelector, inAccessibilityModeSelector,
isDonationModalOpenSelector, isDonationModalOpenSelector,
userSelector, userSelector,
(canFocus, accessibilityMode, open, { theme = 'default' }) => ({ (canFocus, output, accessibilityMode, open, { theme = 'default' }) => ({
canFocus: open ? false : canFocus, canFocus: open ? false : canFocus,
output,
inAccessibilityMode: accessibilityMode, inAccessibilityMode: accessibilityMode,
theme theme
}) })
@ -126,19 +131,31 @@ class Editor extends Component {
this.data = { this.data = {
indexjs: { indexjs: {
model: null, model: null,
state: null state: null,
viewZoneId: null,
decId: null,
viewZoneHeight: null
}, },
indexcss: { indexcss: {
model: null, model: null,
state: null state: null,
viewZoneId: null,
decId: null,
viewZoneHeight: null
}, },
indexhtml: { indexhtml: {
model: null, model: null,
state: null state: null,
viewZoneId: null,
decId: null,
viewZoneHeight: null
}, },
indexjsx: { indexjsx: {
model: null, model: null,
state: null state: null,
viewZoneId: null,
decId: null,
viewZoneHeight: null
} }
}; };
@ -208,7 +225,7 @@ class Editor extends Component {
const editableRegion = [...challengeFiles[key].editableRegionBoundaries]; const editableRegion = [...challengeFiles[key].editableRegionBoundaries];
if (editableRegion.length === 2) if (editableRegion.length === 2)
this.decorateForbiddenRanges(model, editableRegion); this.decorateForbiddenRanges(key, editableRegion);
}); });
return { model: this.data[this.state.fileKey].model }; return { model: this.data[this.state.fileKey].model };
}; };
@ -292,9 +309,207 @@ class Editor extends Component {
const editableBoundaries = [ const editableBoundaries = [
...challengeFiles[fileKey].editableRegionBoundaries ...challengeFiles[fileKey].editableRegionBoundaries
]; ];
this.showEditableRegion(editableBoundaries);
if (editableBoundaries.length === 2) {
this.showEditableRegion(editableBoundaries);
// TODO: is there a nicer approach/way of organising everything that
// avoids the binds? babel-plugin-transform-class-properties ?
const getViewZoneTop = this.getViewZoneTop.bind(this);
const createDescription = this.createDescription.bind(this);
const getOutputZoneTop = this.getOutputZoneTop.bind(this);
const createOutputNode = this.createOutputNode.bind(this);
// TODO: take care that there's no race/ordering problems, with the
// placement of domNode (shouldn't be once it's no longer used in the
// view zone, but make sure!)
this._overlayWidget = {
domNode: null,
getId: function() {
return 'my.overlay.widget';
},
getDomNode: function() {
if (!this.domNode) {
this.domNode = createDescription();
// make sure it's hidden from screenreaders.
this.domNode.setAttribute('aria-hidden', true);
this.domNode.style.background = 'yellow';
this.domNode.style.left = editor.getLayoutInfo().contentLeft + 'px';
this.domNode.style.width =
editor.getLayoutInfo().contentWidth + 'px';
this.domNode.style.top = getViewZoneTop();
}
return this.domNode;
},
getPosition: function() {
if (this.domNode) {
this.domNode.style.width =
editor.getLayoutInfo().contentWidth + 'px';
this.domNode.style.top = getViewZoneTop();
}
return null;
}
};
this._editor.addOverlayWidget(this._overlayWidget);
// TODO: create this and overlayWidget from the same factory.
this._outputWidget = {
domNode: null,
getId: function() {
return 'my.output.widget';
},
getDomNode: function() {
if (!this.domNode) {
this.domNode = createOutputNode();
// make sure it's hidden from screenreaders.
this.domNode.setAttribute('aria-hidden', true);
this.domNode.style.background = 'red';
this.domNode.style.left = editor.getLayoutInfo().contentLeft + 'px';
this.domNode.style.width =
editor.getLayoutInfo().contentWidth + 'px';
this.domNode.style.top = getOutputZoneTop();
}
return this.domNode;
},
getPosition: function() {
if (this.domNode) {
this.domNode.style.width =
editor.getLayoutInfo().contentWidth + 'px';
this.domNode.style.top = getOutputZoneTop();
}
return null;
}
};
this._editor.addOverlayWidget(this._overlayWidget);
this._editor.addOverlayWidget(this._outputWidget);
// TODO: if we keep using a single editor and switching content (rather
// than having multiple open editors), this view zone needs to be
// preserved when the tab changes.
editor.changeViewZones(this.viewZoneCallback);
editor.changeViewZones(this.outputZoneCallback);
editor.onDidScrollChange(() => {
editor.layoutOverlayWidget(this._overlayWidget);
editor.layoutOverlayWidget(this._outputWidget);
});
}
}; };
viewZoneCallback = changeAccessor => {
const { fileKey } = this.state;
// TODO: is there any point creating this here? I know it's cached, but
// would it not be better just sourced from the overlayWidget?
const domNode = this.createDescription();
// make sure the overlayWidget has resized before using it to set the height
domNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px';
domNode.style.top = this.getViewZoneTop();
// TODO: set via onComputedHeight?
this.data[fileKey].viewZoneHeight = domNode.offsetHeight;
var background = document.createElement('div');
background.style.background = 'lightgreen';
// We have to wait for the viewZone to finish rendering before adjusting the
// position of the overlayWidget (i.e. trigger it via onComputedHeight). If
// not the editor may report the wrong value for position of the lines.
const viewZone = {
afterLineNumber: this.getLineAfterViewZone() - 1,
heightInPx: domNode.offsetHeight,
domNode: background,
onComputedHeight: () =>
this._editor.layoutOverlayWidget(this._overlayWidget)
};
this.data[fileKey].viewZoneId = changeAccessor.addZone(viewZone);
};
// TODO: this is basically the same as viewZoneCallback, so DRY them out.
outputZoneCallback = changeAccessor => {
const { fileKey } = this.state;
// TODO: is there any point creating this here? I know it's cached, but
// would it not be better just sourced from the overlayWidget?
const outputNode = this.createOutputNode();
// make sure the overlayWidget has resized before using it to set the height
outputNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px';
outputNode.style.top = this.getOutputZoneTop();
// TODO: set via onComputedHeight?
this.data[fileKey].outputZoneHeight = outputNode.offsetHeight;
var background = document.createElement('div');
background.style.background = 'lightpink';
// We have to wait for the viewZone to finish rendering before adjusting the
// position of the overlayWidget (i.e. trigger it via onComputedHeight). If
// not the editor may report the wrong value for position of the lines.
const viewZone = {
afterLineNumber: this.getLineAfterEditableRegion() - 1,
heightInPx: outputNode.offsetHeight,
domNode: background,
onComputedHeight: () =>
this._editor.layoutOverlayWidget(this._outputWidget)
};
this.data[fileKey].outputZoneId = changeAccessor.addZone(viewZone);
};
createDescription() {
if (this._domNode) return this._domNode;
const { description } = this.props;
var domNode = document.createElement('div');
var desc = document.createElement('div');
var button = document.createElement('button');
button.innerHTML = 'Run the Tests (Ctrl + Enter)';
button.onclick = () => {
const { executeChallenge } = this.props;
executeChallenge();
};
domNode.appendChild(desc);
domNode.appendChild(button);
desc.innerHTML = description;
desc.style.background = 'white';
domNode.style.background = 'lightgreen';
// TODO: the solution is probably just to use an overlay that's forced to
// follow the decorations.
// TODO: this is enough for Firefox, but Chrome needs more before the
// user can select text by clicking and dragging.
domNode.style.userSelect = 'text';
// The z-index needs increasing as ViewZones default to below the lines.
domNode.style.zIndex = '10';
this._domNode = domNode;
return domNode;
}
createOutputNode() {
if (this._outputNode) return this._outputNode;
const outputNode = document.createElement('div');
outputNode.innerHTML = 'TESTS GO HERE';
outputNode.style.background = 'lightblue';
// The z-index needs increasing as ViewZones default to below the lines.
outputNode.style.zIndex = '10';
this._outputNode = outputNode;
return outputNode;
}
focusOnHotkeys() { focusOnHotkeys() {
if (this.props.containerRef.current) { if (this.props.containerRef.current) {
this.props.containerRef.current.focus(); this.props.containerRef.current.focus();
@ -307,6 +522,11 @@ class Editor extends Component {
onChange = editorValue => { onChange = editorValue => {
const { updateFile } = this.props; const { updateFile } = this.props;
// TODO: widgets can go
const widget = this.data[this.state.fileKey].widget;
if (widget) {
this._editor.layoutContentWidget(widget);
}
updateFile({ key: this.state.fileKey, editorValue }); updateFile({ key: this.state.fileKey, editorValue });
}; };
@ -333,6 +553,7 @@ class Editor extends Component {
}; };
showEditableRegion(editableBoundaries) { showEditableRegion(editableBoundaries) {
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
// are the user has not edited yet. If so, move to the start of the editable // are the user has not edited yet. If so, move to the start of the editable
// region. // region.
@ -379,7 +600,53 @@ class Editor extends Component {
return target.deltaDecorations([], lineDecoration.concat(inlineDecoration)); return target.deltaDecorations([], lineDecoration.concat(inlineDecoration));
} }
decorateForbiddenRanges(model, editableRegion) { // NOTE: this is where the view zone *should* be, not necessarily were it
// currently is. (see getLineAfterViewZone)
// TODO: DRY this and getOutputZoneTop out.
getViewZoneTop() {
const heightDelta = this.data[this.state.fileKey].viewZoneHeight || 0;
const top =
this._editor.getTopForLineNumber(this.getLineAfterViewZone()) -
heightDelta -
this._editor.getScrollTop() +
'px';
return top;
}
getOutputZoneTop() {
const heightDelta = this.data[this.state.fileKey].outputZoneHeight || 0;
const top =
this._editor.getTopForLineNumber(this.getLineAfterEditableRegion()) -
heightDelta -
this._editor.getScrollTop() +
'px';
return top;
}
// It's not possible to directly access the current view zone so we track
// the region it should cover instead.
// TODO: DRY
getLineAfterViewZone() {
const { fileKey } = this.state;
return (
this.data[fileKey].model.getDecorationRange(this.data[fileKey].decId)
.endLineNumber + 1
);
}
getLineAfterEditableRegion() {
const { fileKey } = this.state;
return this.data[fileKey].model.getDecorationRange(
this.data[fileKey].endEditDecId
).startLineNumber;
}
decorateForbiddenRanges(key, editableRegion) {
const model = this.data[key].model;
const forbiddenRanges = [ const forbiddenRanges = [
[1, editableRegion[0]], [1, editableRegion[0]],
[editableRegion[1], model.getLineCount()] [editableRegion[1], model.getLineCount()]
@ -398,6 +665,10 @@ class Editor extends Component {
ranges ranges
); );
// TODO: avoid getting from array
this.data[key].decId = decIds[0];
this.data[key].endEditDecId = decIds[1];
// TODO refactor this mess // TODO refactor this mess
// TODO this listener needs to be replaced on reset. // TODO this listener needs to be replaced on reset.
model.onDidChangeContent(e => { model.onDidChangeContent(e => {
@ -453,17 +724,57 @@ class Editor extends Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
// 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) {
this.updateEditorValues(); this.updateEditorValues();
} }
if (this.props.dimensions !== prevProps.dimensions && this._editor) { if (this._editor) {
this._editor.layout(); if (this.props.output !== prevProps.output && this._outputNode) {
// TODO: output gets wiped when the preview gets updated, keeping the
// display is an anti-pattern (the render should not ignore props!).
// The correct solution is probably to create a new redux variable
// (shownHint,maybe) and have that persist through previews. But, for
// now:
if (this.props.output) {
this._outputNode.innerHTML = this.props.output;
if (this.data[fileKey].decId) {
this.updateOutputZone();
}
}
}
if (this.props.dimensions !== prevProps.dimensions) {
// Order matters here. The view zones need to know the new editor
// dimensions in order to render correctly.
this._editor.layout();
if (this.data[fileKey].decId) {
console.log('data', this.data[fileKey]);
this.updateViewZone();
this.updateOutputZone();
}
}
} }
} }
// TODO: DRY (there's going to be a lot of that)
updateOutputZone() {
this._editor.changeViewZones(changeAccessor => {
changeAccessor.removeZone(this.data[this.state.fileKey].outputZoneId);
this.outputZoneCallback(changeAccessor);
});
}
updateViewZone() {
this._editor.changeViewZones(changeAccessor => {
changeAccessor.removeZone(this.data[this.state.fileKey].viewZoneId);
this.viewZoneCallback(changeAccessor);
});
}
componentWillUnmount() { componentWillUnmount() {
this.setState({ fileKey: null }); this.setState({ fileKey: null });
this.data = null; this.data = null;
@ -475,6 +786,8 @@ class Editor extends Component {
// TODO: tabs should be dynamically created from the challengeFiles // TODO: tabs should be dynamically created from the challengeFiles
// TODO: is the key necessary? Try switching themes without it. // 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} />}> <Suspense fallback={<Loader timeout={600} />}>
<span className='notranslate'> <span className='notranslate'>

View File

@ -221,12 +221,13 @@ class ShowClassic extends Component {
renderEditor() { renderEditor() {
const { files } = this.props; const { files } = this.props;
const { description } = this.getChallenge();
return ( return (
files && ( files && (
<Editor <Editor
challengeFiles={files} challengeFiles={files}
containerRef={this.containerRef} containerRef={this.containerRef}
description={description}
ref={this.editorRef} ref={this.editorRef}
/> />
) )