feat: create action row and style Multi-file editor (#41579)

* feat: add action row & style editor

* fix: separate conditional for splittter and reflex element

* fix: move jaws whenever a line is deleted or added

* feat: keep line indicator inside editable region

* feat: add submit button and restyle decorator

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2021-04-09 23:18:54 +03:00
committed by GitHub
parent c1ee2720b3
commit f075837311
6 changed files with 347 additions and 128 deletions

View File

@ -1,13 +1,58 @@
import React from 'react';
import PropTypes from 'prop-types';
import EditorTabs from './EditorTabs';
const ActionRow = () => (
<div className='action-row'>
<EditorTabs />
</div>
);
const propTypes = {
block: PropTypes.string,
showConsole: PropTypes.bool,
showNotes: PropTypes.bool,
showPreview: PropTypes.bool,
superBlock: PropTypes.string,
switchDisplayTab: PropTypes.func
};
const ActionRow = ({ switchDisplayTab, showPreview, showConsole }) => {
const restartStep = () => {
console.log('restart');
};
return (
<div className='action-row'>
<div>
<h5 className='breadcrumbs-demo'>
Responsive Web Design &gt; Basic HTML Cat Photo App &gt;{' '}
<span>Step 23 of 213</span>
</h5>
</div>
<div className='tabs-row'>
<EditorTabs />
<button
className='restart-step-tab'
onClick={() => restartStep()}
role='tab'
>
Restart Step
</button>
<div className='panel-display-tabs'>
<button
className={showConsole ? 'active-tab' : ''}
onClick={() => switchDisplayTab('showConsole')}
role='tab'
>
JS Console
</button>
<button
className={showPreview ? 'active-tab' : ''}
onClick={() => switchDisplayTab('showPreview')}
role='tab'
>
Show Preview
</button>
</div>
</div>
</div>
);
};
ActionRow.propTypes = propTypes;
ActionRow.displayName = 'ActionRow';
export default ActionRow;

View File

@ -29,6 +29,20 @@ const reflexProps = {
};
class DesktopLayout extends Component {
constructor(props) {
super(props);
this.state = { showNotes: false, showPreview: true, showConsole: false };
this.switchDisplayTab = this.switchDisplayTab.bind(this);
}
switchDisplayTab(displayTab) {
this.setState(state => {
return {
[displayTab]: !state[displayTab]
};
});
}
getChallengeFile() {
const { challengeFiles } = this.props;
return first(Object.keys(challengeFiles).map(key => challengeFiles[key]));
@ -45,16 +59,30 @@ class DesktopLayout extends Component {
hasEditableBoundries
} = this.props;
const { showPreview, showConsole } = this.state;
const challengeFile = this.getChallengeFile();
const projectBasedChallenge = showUpcomingChanges && hasEditableBoundries;
const isPreviewDisplayable = projectBasedChallenge
? showPreview && hasPreview
: hasPreview;
const isConsoleDisplayable = projectBasedChallenge ? showConsole : true;
return (
<Fragment>
{showUpcomingChanges && hasEditableBoundries && <ActionRow />}
{projectBasedChallenge && (
<ActionRow switchDisplayTab={this.switchDisplayTab} {...this.state} />
)}
<ReflexContainer className='desktop-layout' orientation='vertical'>
<ReflexElement flex={1} {...resizeProps}>
{instructions}
</ReflexElement>
<ReflexSplitter propagate={true} {...resizeProps} />
{!projectBasedChallenge && (
<ReflexElement flex={1} {...resizeProps}>
{instructions}
</ReflexElement>
)}
{!projectBasedChallenge && (
<ReflexSplitter propagate={true} {...resizeProps} />
)}
<ReflexElement flex={1} {...resizeProps}>
{challengeFile && (
<ReflexContainer key={challengeFile.key} orientation='horizontal'>
@ -68,15 +96,21 @@ class DesktopLayout extends Component {
</Fragment>
}
</ReflexElement>
<ReflexSplitter propagate={true} {...resizeProps} />
<ReflexElement flex={0.25} {...reflexProps} {...resizeProps}>
{testOutput}
</ReflexElement>
{isConsoleDisplayable && (
<ReflexSplitter propagate={true} {...resizeProps} />
)}
{isConsoleDisplayable && (
<ReflexElement flex={0.25} {...reflexProps} {...resizeProps}>
{testOutput}
</ReflexElement>
)}
</ReflexContainer>
)}
</ReflexElement>
{hasPreview && <ReflexSplitter propagate={true} {...resizeProps} />}
{hasPreview && (
{isPreviewDisplayable && (
<ReflexSplitter propagate={true} {...resizeProps} />
)}
{isPreviewDisplayable && (
<ReflexElement flex={0.7} {...resizeProps}>
{preview}
</ReflexElement>

View File

@ -12,7 +12,9 @@ import {
saveEditorContent,
setEditorFocusability,
setAccessibilityMode,
updateFile
updateFile,
challengeTestsSelector,
submitChallenge
} from '../redux';
import { userSelector, isDonationModalOpenSelector } from '../../../redux';
import { Loader } from '../../../components/helpers';
@ -43,6 +45,8 @@ const propTypes = {
saveEditorContent: PropTypes.func.isRequired,
setAccessibilityMode: PropTypes.func.isRequired,
setEditorFocusability: PropTypes.func,
submitChallenge: PropTypes.func,
tests: PropTypes.arrayOf(PropTypes.object),
theme: PropTypes.string,
updateFile: PropTypes.func.isRequired
};
@ -53,11 +57,20 @@ const mapStateToProps = createSelector(
inAccessibilityModeSelector,
isDonationModalOpenSelector,
userSelector,
(canFocus, output, accessibilityMode, open, { theme = 'default' }) => ({
challengeTestsSelector,
(
canFocus,
output,
accessibilityMode,
open,
{ theme = 'default' },
tests
) => ({
canFocus: open ? false : canFocus,
output,
inAccessibilityMode: accessibilityMode,
theme
theme,
tests
})
);
@ -66,7 +79,8 @@ const mapDispatchToProps = {
saveEditorContent,
setAccessibilityMode,
setEditorFocusability,
updateFile
updateFile,
submitChallenge
};
const modeMap = {
@ -140,6 +154,7 @@ class Editor extends Component {
viewZoneId: null,
startEditDecId: null,
endEditDecId: null,
insideEditDecId: null,
viewZoneHeight: null
};
@ -182,6 +197,13 @@ class Editor extends Component {
this.focusOnEditor = this.focusOnEditor.bind(this);
}
getEditableRegion = () => {
const { challengeFiles, fileKey } = this.props;
return challengeFiles[fileKey].editableRegionBoundaries
? [...challengeFiles[fileKey].editableRegionBoundaries]
: [];
};
editorWillMount = monaco => {
this._monaco = monaco;
const { challengeFiles, fileKey } = this.props;
@ -201,9 +223,7 @@ class Editor extends Component {
);
this.data.model = model;
const editableRegion = challengeFiles[fileKey].editableRegionBoundaries
? [...challengeFiles[fileKey].editableRegionBoundaries]
: [];
const editableRegion = this.getEditableRegion();
if (editableRegion.length === 2)
this.decorateForbiddenRanges(editableRegion);
@ -224,7 +244,6 @@ class Editor extends Component {
editorDidMount = (editor, monaco) => {
this._editor = editor;
const { challengeFiles, fileKey } = this.props;
editor.updateOptions({
accessibilitySupport: this.props.inAccessibilityMode ? 'on' : 'auto'
});
@ -286,9 +305,7 @@ class Editor extends Component {
}
});
const editableBoundaries = challengeFiles[fileKey].editableRegionBoundaries
? [...challengeFiles[fileKey].editableRegionBoundaries]
: [];
const editableBoundaries = this.getEditableRegion();
if (editableBoundaries.length === 2) {
// TODO: is there a nicer approach/way of organising everything that
@ -363,7 +380,7 @@ class Editor extends Component {
this.data.viewZoneHeight = domNode.offsetHeight;
var background = document.createElement('div');
background.style.background = 'lightgreen';
// 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
@ -392,7 +409,7 @@ class Editor extends Component {
this.data.outputZoneHeight = outputNode.offsetHeight;
var background = document.createElement('div');
background.style.background = 'lightpink';
// 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
@ -413,19 +430,14 @@ class Editor extends Component {
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);
var descContainer = document.createElement('div');
descContainer.classList.add('description-container');
domNode.classList.add('editor-upper-jaw');
domNode.appendChild(descContainer);
descContainer.appendChild(desc);
desc.innerHTML = description;
desc.style.background = 'white';
domNode.style.background = 'lightgreen';
// 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
@ -436,7 +448,7 @@ class Editor extends Component {
domNode.setAttribute('aria-hidden', true);
domNode.style.background = 'lightYellow';
// domNode.style.background = 'lightYellow';
domNode.style.left = this._editor.getLayoutInfo().contentLeft + 'px';
domNode.style.width = this._editor.getLayoutInfo().contentWidth + 'px';
domNode.style.top = this.getViewZoneTop();
@ -449,11 +461,23 @@ class Editor extends Component {
const outputNode = document.createElement('div');
const statusNode = document.createElement('div');
const hintNode = document.createElement('div');
outputNode.appendChild(statusNode);
outputNode.appendChild(hintNode);
const editorActionRow = document.createElement('div');
editorActionRow.classList.add('action-row-container');
outputNode.classList.add('editor-lower-jaw');
outputNode.appendChild(editorActionRow);
hintNode.setAttribute('id', 'test-output');
statusNode.setAttribute('id', 'test-status');
statusNode.innerHTML = '// tests';
var button = document.createElement('button');
button.setAttribute('id', 'test-button');
button.classList.add('btn-block');
button.innerHTML = 'Check Your Code (Ctrl + Enter)';
editorActionRow.appendChild(button);
editorActionRow.appendChild(statusNode);
editorActionRow.appendChild(hintNode);
button.onclick = () => {
const { executeChallenge } = this.props;
executeChallenge();
};
// TODO: does it?
// The z-index needs increasing as ViewZones default to below the lines.
@ -528,6 +552,19 @@ class Editor extends Component {
return target.deltaDecorations(oldIds, [lineDecoration]);
}
highlightEditableLines(stickiness, target, range, oldIds = []) {
const lineDecoration = {
range,
options: {
isWholeLine: true,
linesDecorationsClassName: 'myEditableLineDecoration',
className: 'do-not-edit',
stickiness
}
};
return target.deltaDecorations(oldIds, [lineDecoration]);
}
highlightText(stickiness, target, range, oldIds = []) {
const inlineDecoration = {
range,
@ -655,6 +692,17 @@ class Editor extends Component {
return this.positionsToRange(model, positions);
});
const editableRange = this.positionsToRange(model, [
editableRegion[0] + 1,
editableRegion[1] - 1
]);
this.data.insideEditDecId = this.highlightEditableLines(
this._monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
model,
editableRange
);
// if the forbidden range includes the top of the editor
// we simply don't add those decorations
if (forbiddenRanges[0][1] > 0) {
@ -743,53 +791,25 @@ class Editor extends Component {
});
};
// Make sure the zone tracks the decoration (i.e. the region)
const handleHintsZoneChange = id => {
const startOfZone = toStartOfLine(
model.getDecorationRange(id)
).collapseToStart();
// the decoration needs adjusting if the user creates a line immediately
// before the greyed out region...
const lineOneRange = this.translateRange(startOfZone, -2);
// or immediately after it
const lineTwoRange = this.translateRange(startOfZone, -1);
for (const lineRange of newLineRanges) {
const shouldMoveZone = this._monaco.Range.areIntersectingOrTouching(
lineRange,
lineOneRange.plusRange(lineTwoRange)
);
if (shouldMoveZone) {
this.updateOutputZone();
}
// Make sure the zone tracks the decoration (i.e. the region), which might
// have changed if a line has been added or removed
const handleHintsZoneChange = () => {
if (newLineRanges.length > 0 || deletedLine > 0) {
this.updateOutputZone();
}
};
// Make sure the zone tracks the decoration (i.e. the region)
const handleDescriptionZoneChange = id => {
const endOfZone = toLastLine(
model.getDecorationRange(id)
).collapseToStart();
// the decoration needs adjusting if the user creates a line immediately
// before the editable region.
const lineOneRange = this.translateRange(endOfZone, -1);
for (const lineRange of newLineRanges) {
const shouldMoveZone = this._monaco.Range.areIntersectingOrTouching(
lineRange,
lineOneRange
);
if (shouldMoveZone) {
this.updateViewZone();
}
// Make sure the zone tracks the decoration (i.e. the region), which might
// have changed if a line has been added or removed
const handleDescriptionZoneChange = () => {
if (newLineRanges.length > 0 || deletedLine > 0) {
this.updateViewZone();
}
};
// Stops the greyed out region from covering the editable region. Does not
// change the font decoration.
const preventOverlap = id => {
const preventOverlap = (id, stickiness, highlightFunction) => {
// Even though the decoration covers the whole line, it has a
// startColumn that moves. toStartOfLine ensures that the
// comparison detects if any change has occurred on that line
@ -821,17 +841,15 @@ class Editor extends Component {
if (touchingDeleted) {
// TODO: if they undo this should be reversed
const decorations = this.highlightLines(
this._monaco.editor.TrackedRangeStickiness
.NeverGrowsWhenTypingAtEdges,
const decorations = highlightFunction(
stickiness,
model,
newCoveringRange,
[id]
id
);
this.updateOutputZone();
// when there's a change, decorations will be [oldId, newId]
return decorations.slice(-1)[0];
return decorations;
} else {
return id;
}
@ -839,7 +857,17 @@ class Editor extends Component {
// we only need to handle the special case of the second region being
// pulled up, the first region already behaves correctly.
this.data.endEditDecId = preventOverlap(this.data.endEditDecId);
this.data.endEditDecId = preventOverlap(
this.data.endEditDecId,
this._monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
this.highlightLines
);
this.data.insideEditDecId = preventOverlap(
this.data.insideEditDecId,
this._monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
this.highlightEditableLines
);
// TODO: do the same for the description widget
// this has to be handle differently, because we care about the END
@ -847,10 +875,10 @@ class Editor extends Component {
// if the editable region includes the first line, the first decoration
// will be missing.
if (this.data.startEditDecId) {
handleDescriptionZoneChange(this.data.startEditDecId);
handleDescriptionZoneChange();
warnUser(this.data.startEditDecId);
}
handleHintsZoneChange(this.data.endEditDecId);
handleHintsZoneChange();
warnUser(this.data.endEditDecId);
});
}
@ -889,7 +917,47 @@ class Editor extends Component {
}
if (this._editor) {
const { output } = this.props;
const { output, tests } = this.props;
const editableRegion = this.getEditableRegion();
if (this.props.tests !== prevProps.tests && editableRegion.length === 2) {
const challengeComplete = tests.every(test => test.pass && !test.err);
const chellengeHasErrors = tests.some(test => test.err);
if (challengeComplete) {
let testButton = document.getElementById('test-button');
testButton.innerHTML =
'Submit your code and go to next challenge (Ctrl + Enter)';
testButton.onclick = () => {
const { submitChallenge } = this.props;
submitChallenge();
};
let editableRegionDecorators = document.getElementsByClassName(
'myEditableLineDecoration'
);
if (editableRegionDecorators.length > 0) {
for (var i of editableRegionDecorators) {
i.classList.add('tests-passed');
}
}
document.getElementById('test-output').innerHTML = '';
document.getElementById('test-status').innerHTML =
'&#9989; Step completed.';
} else if (chellengeHasErrors) {
const wordsArray = [
"Not quite. Here's a hint:",
'Try again. This might help:',
'Keep trying. A quick hint for you:',
"You're getting there. This may help:",
"Hang in there. You'll get there. A hint:",
"Don't give up. Here's a hint to get you thinking:"
];
document.getElementById('test-status').innerHTML = `✖️ ${
wordsArray[Math.floor(Math.random() * wordsArray.length)]
}`;
document.getElementById('test-output').innerHTML = `${output[1]}`;
}
}
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!).
@ -897,14 +965,6 @@ class Editor extends Component {
// (shownHint,maybe) and have that persist through previews. But, for
// now:
if (output) {
if (output[0]) {
document.getElementById('test-status').innerHTML = output[0];
}
if (output[1]) {
document.getElementById('test-output').innerHTML = output[1];
}
// if either id exists, the editable region exists
// TODO: add a layer of abstraction: we should be interacting with
// the editable region, not the ids

View File

@ -6,10 +6,6 @@
color: var(--primary-color);
}
[widgetid='my.overlay.widget'] {
padding: 10px;
}
.vs .monaco-scrollable-element > .scrollbar > .slider {
z-index: 11;
}
@ -17,3 +13,64 @@
.editor-container {
background: var(--editor-background);
}
.breadcrumbs-demo {
font-size: 16px;
}
.breadcrumbs-demo span {
font-weight: bold;
}
.tabs-row {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.action-row button {
padding: 4px 16px;
}
.active-tab {
border-color: var(--secondary-color);
background-color: var(--secondary-color);
color: var(--secondary-background);
}
/* [widgetid='my.overlay.widget'] {
padding: 10px;
} */
.editor-upper-jaw,
.editor-lower-jaw {
padding: 15px 15px 15px 0px;
}
.action-row-container,
.description-container {
background-color: var(--secondary-background);
padding: 10px;
border: 2px solid var(--tertiary-background);
max-width: 700px;
}
#description p:last-child {
margin: 0px;
}
#test-status {
padding-bottom: 5px;
padding-top: 5px;
}
.myEditableLineDecoration {
background-color: var(--gray-45);
width: 15px !important;
margin-left: 5px !important;
margin-right: 5px !important;
}
.myEditableLineDecoration.tests-passed {
background-color: #4caf50;
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from '../../../components/helpers/index';
import './challenge-title.css';
import i18next from 'i18next';
const propTypes = {
block: PropTypes.string,
superBlock: PropTypes.string
};
function BreadCrumb({ block, superBlock }) {
return (
<div className='challenge-title-breadcrumbs'>
<Link
className='breadcrumb-left'
state={{ breadcrumbBlockClick: block }}
to={`/learn/${superBlock}`}
>
<span className='ellipsis'>
{i18next.t(`intro:${superBlock}.title`)}
</span>
</Link>
<div className='breadcrumb-center' />
<Link
className='breadcrumb-right'
state={{ breadcrumbBlockClick: block }}
to={`/learn/${superBlock}/#${block}`}
>
{i18next.t(`intro:${superBlock}.blocks.${block}.title`)}
</Link>
</div>
);
}
BreadCrumb.displayName = 'BreadCrumb';
BreadCrumb.propTypes = propTypes;
export default BreadCrumb;

View File

@ -1,10 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from '../../../components/helpers/index';
import i18next from 'i18next';
import './challenge-title.css';
import GreenPass from '../../../assets/icons/GreenPass';
import i18next from 'i18next';
import BreadCrumb from './BreadCrumb';
const propTypes = {
block: PropTypes.string,
@ -31,25 +32,7 @@ function ChallengeTitle({
{i18next.t('misc.translation-pending')}
</Link>
)}
<div className='challenge-title-breadcrumbs'>
<Link
className='breadcrumb-left'
state={{ breadcrumbBlockClick: block }}
to={`/learn/${superBlock}`}
>
<span className='ellipsis'>
{i18next.t(`intro:${superBlock}.title`)}
</span>
</Link>
<div className='breadcrumb-center' />
<Link
className='breadcrumb-right'
state={{ breadcrumbBlockClick: block }}
to={`/learn/${superBlock}/#${block}`}
>
{i18next.t(`intro:${superBlock}.blocks.${block}.title`)}
</Link>
</div>
<BreadCrumb block={block} superBlock={superBlock} />
<div className='challenge-title'>
<div className='title-text'>
<b>{children}</b>