feat: enable reset in multifile editor (#43617)
* feat: dispatch resetChallenge action * fix: copy challengeFiles instead of in-place sort * fix: handle null updateFile payloads in redux * refactor: reorganise region initialization * refactor: pull code into editorDidMount Then we can rely on the presence of the editor and monaco and don't have litter the code with null checks. * refactor: use better name for editable region init * refactor: remove unused decoration * refactor: rename forbidden region init functions * fix: keep all challengeFiles when resetting * refactor: remove unused decoration class * fix: reinitialize editor on reset * fix: stop adding multiple scroll listeners Since the challengeFile update on each keystroke extra (unnecessary) adding of listeners slowed the editor to a crawl. * fix: only scroll to editor on mount Rather than on any edit. * refactor: remove logs and comments * fix: rename toSortedArray and fix broken test * fix: check for null not falsy in updateFile * fix: only update project features when project * fix: only reset if editor contents have changed * feat: focus on editor after reset
This commit is contained in:
committed by
GitHub
parent
eb6d3e214f
commit
e4ba0e23ea
@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex';
|
import { ReflexContainer, ReflexSplitter, ReflexElement } from 'react-reflex';
|
||||||
import envData from '../../../../../config/env.json';
|
import envData from '../../../../../config/env.json';
|
||||||
import { toSortedArray } from '../../../../../utils/sort-files';
|
import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles';
|
||||||
import EditorTabs from './EditorTabs';
|
import EditorTabs from './EditorTabs';
|
||||||
import ActionRow from './action-row.tsx';
|
import ActionRow from './action-row.tsx';
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ class DesktopLayout extends Component {
|
|||||||
|
|
||||||
getChallengeFile() {
|
getChallengeFile() {
|
||||||
const { challengeFiles } = this.props;
|
const { challengeFiles } = this.props;
|
||||||
return first(toSortedArray(challengeFiles));
|
return first(sortChallengeFiles(challengeFiles));
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -3,7 +3,7 @@ import React, { Component } from 'react';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import { toSortedArray } from '../../../../../utils/sort-files';
|
import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles';
|
||||||
import {
|
import {
|
||||||
toggleVisibleEditor,
|
toggleVisibleEditor,
|
||||||
visibleEditorsSelector,
|
visibleEditorsSelector,
|
||||||
@ -39,7 +39,7 @@ class EditorTabs extends Component {
|
|||||||
const { challengeFiles, toggleVisibleEditor, visibleEditors } = this.props;
|
const { challengeFiles, toggleVisibleEditor, visibleEditors } = this.props;
|
||||||
return (
|
return (
|
||||||
<div className='monaco-editor-tabs'>
|
<div className='monaco-editor-tabs'>
|
||||||
{toSortedArray(challengeFiles).map(challengeFile => (
|
{sortChallengeFiles(challengeFiles).map(challengeFile => (
|
||||||
<button
|
<button
|
||||||
aria-selected={visibleEditors[challengeFile.fileKey]}
|
aria-selected={visibleEditors[challengeFile.fileKey]}
|
||||||
className='monaco-editor-tab'
|
className='monaco-editor-tab'
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
import BreadCrumb from '../components/bread-crumb';
|
import BreadCrumb from '../components/bread-crumb';
|
||||||
|
import { resetChallenge } from '../redux';
|
||||||
import EditorTabs from './EditorTabs';
|
import EditorTabs from './EditorTabs';
|
||||||
|
|
||||||
interface ActionRowProps {
|
interface ActionRowProps {
|
||||||
@ -9,18 +11,21 @@ interface ActionRowProps {
|
|||||||
showPreview: boolean;
|
showPreview: boolean;
|
||||||
superBlock: string;
|
superBlock: string;
|
||||||
switchDisplayTab: (displayTab: string) => void;
|
switchDisplayTab: (displayTab: string) => void;
|
||||||
|
resetChallenge: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
resetChallenge
|
||||||
|
};
|
||||||
|
|
||||||
const ActionRow = ({
|
const ActionRow = ({
|
||||||
switchDisplayTab,
|
switchDisplayTab,
|
||||||
showPreview,
|
showPreview,
|
||||||
showConsole,
|
showConsole,
|
||||||
superBlock,
|
superBlock,
|
||||||
block
|
block,
|
||||||
|
resetChallenge
|
||||||
}: ActionRowProps): JSX.Element => {
|
}: ActionRowProps): JSX.Element => {
|
||||||
const restartStep = () => {
|
|
||||||
console.log('restart');
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className='action-row'>
|
<div className='action-row'>
|
||||||
<div className='breadcrumbs-demo'>
|
<div className='breadcrumbs-demo'>
|
||||||
@ -30,7 +35,7 @@ const ActionRow = ({
|
|||||||
<EditorTabs />
|
<EditorTabs />
|
||||||
<button
|
<button
|
||||||
className='restart-step-tab'
|
className='restart-step-tab'
|
||||||
onClick={() => restartStep()}
|
onClick={resetChallenge}
|
||||||
role='tab'
|
role='tab'
|
||||||
>
|
>
|
||||||
Restart Step
|
Restart Step
|
||||||
@ -57,4 +62,5 @@ const ActionRow = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ActionRow.displayName = 'ActionRow';
|
ActionRow.displayName = 'ActionRow';
|
||||||
export default ActionRow;
|
|
||||||
|
export default connect(null, mapDispatchToProps)(ActionRow);
|
||||||
|
@ -262,9 +262,6 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
modeMap[challengeFile?.ext ?? 'html']
|
modeMap[challengeFile?.ext ?? 'html']
|
||||||
);
|
);
|
||||||
data.model = model;
|
data.model = model;
|
||||||
const editableRegion = getEditableRegionFromRedux();
|
|
||||||
|
|
||||||
if (editableRegion.length === 2) decorateForbiddenRanges(editableRegion);
|
|
||||||
|
|
||||||
// TODO: do we need to return this?
|
// TODO: do we need to return this?
|
||||||
return { model };
|
return { model };
|
||||||
@ -274,13 +271,16 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
// changes coming from outside the editor (such as code resets).
|
// changes coming from outside the editor (such as code resets).
|
||||||
const updateEditorValues = () => {
|
const updateEditorValues = () => {
|
||||||
const { challengeFiles, fileKey } = props;
|
const { challengeFiles, fileKey } = props;
|
||||||
const { model } = dataRef.current[fileKey];
|
const { model } = data;
|
||||||
|
|
||||||
const newContents = challengeFiles?.find(
|
const newContents = challengeFiles?.find(
|
||||||
challengeFile => challengeFile.fileKey === fileKey
|
challengeFile => challengeFile.fileKey === fileKey
|
||||||
)?.contents;
|
)?.contents;
|
||||||
if (model?.getValue() !== newContents) {
|
if (model?.getValue() !== newContents) {
|
||||||
model?.setValue(newContents ?? '');
|
model?.setValue(newContents ?? '');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -292,6 +292,12 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
data.editor = editor;
|
data.editor = editor;
|
||||||
|
|
||||||
|
if (isProject()) {
|
||||||
|
initializeProjectFeatures();
|
||||||
|
addContentChangeListener();
|
||||||
|
showEditableRegion(editor);
|
||||||
|
}
|
||||||
|
|
||||||
const storedAccessibilityMode = () => {
|
const storedAccessibilityMode = () => {
|
||||||
const accessibility = store.get('accessibilityMode') as boolean;
|
const accessibility = store.get('accessibilityMode') as boolean;
|
||||||
if (!accessibility) {
|
if (!accessibility) {
|
||||||
@ -368,65 +374,6 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
editor.onDidFocusEditorWidget(() => props.setEditorFocusability(true));
|
editor.onDidFocusEditorWidget(() => props.setEditorFocusability(true));
|
||||||
|
|
||||||
const editableBoundaries = getEditableRegionFromRedux();
|
|
||||||
|
|
||||||
if (editableBoundaries.length === 2) {
|
|
||||||
const createWidget = (
|
|
||||||
id: string,
|
|
||||||
domNode: HTMLDivElement,
|
|
||||||
getTop: () => string
|
|
||||||
) => {
|
|
||||||
const getId = () => id;
|
|
||||||
const getDomNode = () => domNode;
|
|
||||||
const getPosition = () => {
|
|
||||||
domNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
|
|
||||||
domNode.style.top = getTop();
|
|
||||||
|
|
||||||
// must return null, so that Monaco knows the widget will position
|
|
||||||
// itself.
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
getId,
|
|
||||||
getDomNode,
|
|
||||||
getPosition
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const descriptionNode = createDescription(editor);
|
|
||||||
|
|
||||||
const outputNode = createOutputNode(editor);
|
|
||||||
|
|
||||||
const descriptionWidget = createWidget(
|
|
||||||
'description.widget',
|
|
||||||
descriptionNode,
|
|
||||||
getDescriptionZoneTop
|
|
||||||
);
|
|
||||||
data.descriptionWidget = descriptionWidget;
|
|
||||||
const outputWidget = createWidget(
|
|
||||||
'output.widget',
|
|
||||||
outputNode,
|
|
||||||
getOutputZoneTop
|
|
||||||
);
|
|
||||||
data.outputWidget = outputWidget;
|
|
||||||
|
|
||||||
editor.addOverlayWidget(descriptionWidget);
|
|
||||||
|
|
||||||
// TODO: order of insertion into the DOM probably matters, revisit once
|
|
||||||
// the tabs have been fixed!
|
|
||||||
|
|
||||||
editor.addOverlayWidget(outputWidget);
|
|
||||||
|
|
||||||
editor.changeViewZones(descriptionZoneCallback);
|
|
||||||
editor.changeViewZones(outputZoneCallback);
|
|
||||||
|
|
||||||
editor.onDidScrollChange(() => {
|
|
||||||
editor.layoutOverlayWidget(descriptionWidget);
|
|
||||||
editor.layoutOverlayWidget(outputWidget);
|
|
||||||
});
|
|
||||||
showEditableRegion(editableBoundaries);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const descriptionZoneCallback = (
|
const descriptionZoneCallback = (
|
||||||
@ -581,28 +528,24 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
updateFile({ fileKey, editorValue, editableRegionBoundaries });
|
updateFile({ fileKey, editorValue, editableRegionBoundaries });
|
||||||
};
|
};
|
||||||
|
|
||||||
function showEditableRegion(editableBoundaries: number[]) {
|
// TODO DRY this and the update function
|
||||||
if (editableBoundaries.length !== 2) return;
|
function initializeForbiddenRegion(
|
||||||
const editor = data.editor;
|
stickiness: number,
|
||||||
if (!editor) return;
|
target: editor.ITextModel,
|
||||||
// TODO: The heuristic has been commented out for now because the cursor
|
range: IRange
|
||||||
// position is not saved at the moment, so it's redundant. I'm leaving it
|
) {
|
||||||
// here for now, in case we decide to save it in future.
|
const lineDecoration = {
|
||||||
// this is a heuristic: if the cursor is at the start of the page, chances
|
range,
|
||||||
// are the user has not edited yet. If so, move to the start of the editable
|
options: {
|
||||||
// region.
|
isWholeLine: true,
|
||||||
// if (
|
linesDecorationsClassName: 'myLineDecoration',
|
||||||
// isEqual({ ..._editor.getPosition() }, { lineNumber: 1, column: 1 })
|
stickiness
|
||||||
// ) {
|
}
|
||||||
editor.setPosition({
|
};
|
||||||
lineNumber: editableBoundaries[0] + 1,
|
return target.deltaDecorations([], [lineDecoration]);
|
||||||
column: 1
|
|
||||||
});
|
|
||||||
editor.revealLinesInCenter(editableBoundaries[0], editableBoundaries[1]);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightLines(
|
function updateForbiddenRegion(
|
||||||
stickiness: number,
|
stickiness: number,
|
||||||
target: editor.ITextModel,
|
target: editor.ITextModel,
|
||||||
range: IRange,
|
range: IRange,
|
||||||
@ -613,14 +556,30 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
options: {
|
options: {
|
||||||
isWholeLine: true,
|
isWholeLine: true,
|
||||||
linesDecorationsClassName: 'myLineDecoration',
|
linesDecorationsClassName: 'myLineDecoration',
|
||||||
className: 'do-not-edit',
|
|
||||||
stickiness
|
stickiness
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return target.deltaDecorations(oldIds, [lineDecoration]);
|
return target.deltaDecorations(oldIds, [lineDecoration]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightEditableLines(
|
// TODO: DRY this and the update function
|
||||||
|
function initializeEditableRegion(
|
||||||
|
stickiness: number,
|
||||||
|
target: editor.ITextModel,
|
||||||
|
range: IRange
|
||||||
|
) {
|
||||||
|
const lineDecoration = {
|
||||||
|
range,
|
||||||
|
options: {
|
||||||
|
isWholeLine: true,
|
||||||
|
linesDecorationsClassName: 'myEditableLineDecoration',
|
||||||
|
stickiness
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return target.deltaDecorations([], [lineDecoration]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditableRegion(
|
||||||
stickiness: number,
|
stickiness: number,
|
||||||
target: editor.ITextModel,
|
target: editor.ITextModel,
|
||||||
range: IRange,
|
range: IRange,
|
||||||
@ -631,30 +590,12 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
options: {
|
options: {
|
||||||
isWholeLine: true,
|
isWholeLine: true,
|
||||||
linesDecorationsClassName: 'myEditableLineDecoration',
|
linesDecorationsClassName: 'myEditableLineDecoration',
|
||||||
className: 'do-not-edit',
|
|
||||||
stickiness
|
stickiness
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return target.deltaDecorations(oldIds, [lineDecoration]);
|
return target.deltaDecorations(oldIds, [lineDecoration]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightText(
|
|
||||||
stickiness: number,
|
|
||||||
target: editor.ITextModel,
|
|
||||||
range: IRange,
|
|
||||||
oldIds: string[] = []
|
|
||||||
) {
|
|
||||||
const inlineDecoration = {
|
|
||||||
range,
|
|
||||||
options: {
|
|
||||||
inlineClassName: 'myInlineDecoration',
|
|
||||||
stickiness
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return target.deltaDecorations(oldIds, [inlineDecoration]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDescriptionZoneTop() {
|
function getDescriptionZoneTop() {
|
||||||
return `${data.descriptionZoneTop}px`;
|
return `${data.descriptionZoneTop}px`;
|
||||||
}
|
}
|
||||||
@ -705,7 +646,20 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function decorateForbiddenRanges(editableRegion: number[]) {
|
function initializeProjectFeatures() {
|
||||||
|
const editor = data.editor;
|
||||||
|
if (editor) {
|
||||||
|
initializeRegions(getEditableRegionFromRedux());
|
||||||
|
addWidgetsToRegions(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProject() {
|
||||||
|
const editableRegionBoundaries = getEditableRegionFromRedux();
|
||||||
|
return editableRegionBoundaries.length === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeRegions(editableRegion: number[]) {
|
||||||
const { model } = data;
|
const { model } = data;
|
||||||
const monaco = monacoRef.current;
|
const monaco = monacoRef.current;
|
||||||
if (!model || !monaco) return;
|
if (!model || !monaco) return;
|
||||||
@ -714,12 +668,12 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
[editableRegion[1], model.getLineCount()]
|
[editableRegion[1], model.getLineCount()]
|
||||||
];
|
];
|
||||||
|
|
||||||
const editableRange = positionsToRange(model, monaco, [
|
const editableRange = positionsToRange(monaco, model, [
|
||||||
editableRegion[0] + 1,
|
editableRegion[0] + 1,
|
||||||
editableRegion[1] - 1
|
editableRegion[1] - 1
|
||||||
]);
|
]);
|
||||||
|
|
||||||
data.insideEditDecId = highlightEditableLines(
|
data.insideEditDecId = initializeEditableRegion(
|
||||||
monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
|
monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
|
||||||
model,
|
model,
|
||||||
editableRange
|
editableRange
|
||||||
@ -729,51 +683,87 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
// we simply don't add those decorations
|
// we simply don't add those decorations
|
||||||
if (forbiddenRegions[0][1] > 0) {
|
if (forbiddenRegions[0][1] > 0) {
|
||||||
const forbiddenRange = positionsToRange(
|
const forbiddenRange = positionsToRange(
|
||||||
model,
|
|
||||||
monaco,
|
monaco,
|
||||||
|
model,
|
||||||
forbiddenRegions[0]
|
forbiddenRegions[0]
|
||||||
);
|
);
|
||||||
// the first range should expand at the top
|
// the first range should expand at the top
|
||||||
// TODO: Unsure what this should be - returns an array, so I added [0] @ojeytonwilliams
|
// TODO: Unsure what this should be - returns an array, so I added [0] @ojeytonwilliams
|
||||||
data.startEditDecId = highlightLines(
|
data.startEditDecId = initializeForbiddenRegion(
|
||||||
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
|
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
|
||||||
model,
|
model,
|
||||||
forbiddenRange
|
forbiddenRange
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
highlightText(
|
|
||||||
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
|
|
||||||
model,
|
|
||||||
forbiddenRange
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const forbiddenRange = positionsToRange(model, monaco, forbiddenRegions[1]);
|
const forbiddenRange = positionsToRange(monaco, model, forbiddenRegions[1]);
|
||||||
// TODO: handle the case the region covers the bottom of the editor
|
// TODO: handle the case the region covers the bottom of the editor
|
||||||
// the second range should expand at the bottom
|
// the second range should expand at the bottom
|
||||||
data.endEditDecId = highlightLines(
|
data.endEditDecId = initializeForbiddenRegion(
|
||||||
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingAfter,
|
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingAfter,
|
||||||
model,
|
model,
|
||||||
forbiddenRange
|
forbiddenRange
|
||||||
)[0];
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
highlightText(
|
function addWidgetsToRegions(editor: editor.IStandaloneCodeEditor) {
|
||||||
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingAfter,
|
const createWidget = (
|
||||||
model,
|
id: string,
|
||||||
forbiddenRange
|
domNode: HTMLDivElement,
|
||||||
);
|
getTop: () => string
|
||||||
|
) => {
|
||||||
|
const getId = () => id;
|
||||||
|
const getDomNode = () => domNode;
|
||||||
|
const getPosition = () => {
|
||||||
|
domNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
|
||||||
|
domNode.style.top = getTop();
|
||||||
|
|
||||||
// The deleted line is always considered to be the one that has moved up.
|
// must return null, so that Monaco knows the widget will position
|
||||||
// - if the user deletes at the end of line 5, line 6 is deleted and
|
// itself.
|
||||||
// - if the user backspaces at the start of line 6, line 6 is deleted
|
return null;
|
||||||
// TODO: handle multiple simultaneous changes (multicursors do this)
|
};
|
||||||
function getDeletedLine(event: editor.IModelContentChangedEvent) {
|
return {
|
||||||
const isDeleted =
|
getId,
|
||||||
event.changes[0].text === '' && event.changes[0].range.endColumn === 1;
|
getDomNode,
|
||||||
return isDeleted ? event.changes[0].range.endLineNumber : 0;
|
getPosition
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptionNode = createDescription(editor);
|
||||||
|
|
||||||
|
const outputNode = createOutputNode(editor);
|
||||||
|
|
||||||
|
if (!data.descriptionWidget) {
|
||||||
|
data.descriptionWidget = createWidget(
|
||||||
|
'description.widget',
|
||||||
|
descriptionNode,
|
||||||
|
getDescriptionZoneTop
|
||||||
|
);
|
||||||
|
editor.addOverlayWidget(data.descriptionWidget);
|
||||||
|
editor.changeViewZones(descriptionZoneCallback);
|
||||||
|
}
|
||||||
|
if (!data.outputWidget) {
|
||||||
|
data.outputWidget = createWidget(
|
||||||
|
'output.widget',
|
||||||
|
outputNode,
|
||||||
|
getOutputZoneTop
|
||||||
|
);
|
||||||
|
editor.addOverlayWidget(data.outputWidget);
|
||||||
|
editor.changeViewZones(outputZoneCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO this listener needs to be replaced on reset.
|
editor.onDidScrollChange(() => {
|
||||||
|
if (data.descriptionWidget)
|
||||||
|
editor.layoutOverlayWidget(data.descriptionWidget);
|
||||||
|
if (data.outputWidget) editor.layoutOverlayWidget(data.outputWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addContentChangeListener() {
|
||||||
|
const { model } = data;
|
||||||
|
const monaco = monacoRef.current;
|
||||||
|
if (!model || !monaco) return;
|
||||||
|
|
||||||
model.onDidChangeContent(e => {
|
model.onDidChangeContent(e => {
|
||||||
// TODO: it would be nice if undoing could remove the warning, but
|
// TODO: it would be nice if undoing could remove the warning, but
|
||||||
// it's probably too hard to track. i.e. if they make two warned edits
|
// it's probably too hard to track. i.e. if they make two warned edits
|
||||||
@ -793,7 +783,7 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
const redecorateEditableRegion = () => {
|
const redecorateEditableRegion = () => {
|
||||||
const coveringRange = getLinesCoveringEditableRegion();
|
const coveringRange = getLinesCoveringEditableRegion();
|
||||||
if (coveringRange) {
|
if (coveringRange) {
|
||||||
data.insideEditDecId = highlightEditableLines(
|
data.insideEditDecId = updateEditableRegion(
|
||||||
monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
|
monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
|
||||||
model,
|
model,
|
||||||
coveringRange,
|
coveringRange,
|
||||||
@ -829,7 +819,7 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
const preventOverlap = (
|
const preventOverlap = (
|
||||||
id: string,
|
id: string,
|
||||||
stickiness: number,
|
stickiness: number,
|
||||||
highlightFunction: typeof highlightLines
|
updateRegion: typeof updateForbiddenRegion
|
||||||
) => {
|
) => {
|
||||||
// Even though the decoration covers the whole line, it has a
|
// Even though the decoration covers the whole line, it has a
|
||||||
// startColumn that moves. toStartOfLine ensures that the
|
// startColumn that moves. toStartOfLine ensures that the
|
||||||
@ -867,7 +857,7 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
|
|
||||||
if (touchingDeleted) {
|
if (touchingDeleted) {
|
||||||
// TODO: if they undo this should be reversed
|
// TODO: if they undo this should be reversed
|
||||||
const decorations = highlightFunction(
|
const decorations = updateRegion(
|
||||||
stickiness,
|
stickiness,
|
||||||
model,
|
model,
|
||||||
newCoveringRange,
|
newCoveringRange,
|
||||||
@ -889,7 +879,7 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
data.endEditDecId = preventOverlap(
|
data.endEditDecId = preventOverlap(
|
||||||
data.endEditDecId,
|
data.endEditDecId,
|
||||||
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
|
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
|
||||||
highlightLines
|
updateForbiddenRegion
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the content has changed, the zones may need moving. Rather than
|
// If the content has changed, the zones may need moving. Rather than
|
||||||
@ -905,13 +895,44 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
warnUser(data.endEditDecId);
|
warnUser(data.endEditDecId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// The deleted line is always considered to be the one that has moved up.
|
||||||
|
// - if the user deletes at the end of line 5, line 6 is deleted and
|
||||||
|
// - if the user backspaces at the start of line 6, line 6 is deleted
|
||||||
|
// TODO: handle multiple simultaneous changes (multicursors do this)
|
||||||
|
function getDeletedLine(event: editor.IModelContentChangedEvent) {
|
||||||
|
const isDeleted =
|
||||||
|
event.changes[0].text === '' && event.changes[0].range.endColumn === 1;
|
||||||
|
return isDeleted ? event.changes[0].range.endLineNumber : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditableRegion(editor: editor.IStandaloneCodeEditor) {
|
||||||
|
const editableRegionBoundaries = getEditableRegionFromRedux();
|
||||||
|
// TODO: The heuristic has been commented out for now because the cursor
|
||||||
|
// position is not saved at the moment, so it's redundant. I'm leaving it
|
||||||
|
// here for now, in case we decide to save it in future.
|
||||||
|
// 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
|
||||||
|
// region.
|
||||||
|
// if (
|
||||||
|
// isEqual({ ..._editor.getPosition() }, { lineNumber: 1, column: 1 })
|
||||||
|
// ) {
|
||||||
|
editor.setPosition({
|
||||||
|
lineNumber: editableRegionBoundaries[0] + 1,
|
||||||
|
column: 1
|
||||||
|
});
|
||||||
|
editor.revealLinesInCenter(
|
||||||
|
editableRegionBoundaries[0],
|
||||||
|
editableRegionBoundaries[1]
|
||||||
|
);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// creates a range covering all the lines in 'positions'
|
// creates a range covering all the lines in 'positions'
|
||||||
// NOTE: positions is an array of [startLine, endLine]
|
// NOTE: positions is an array of [startLine, endLine]
|
||||||
function positionsToRange(
|
function positionsToRange(
|
||||||
model: editor.ITextModel,
|
|
||||||
monaco: typeof monacoEditor,
|
monaco: typeof monacoEditor,
|
||||||
|
model: editor.ITextModel,
|
||||||
[start, end]: [number, number]
|
[start, end]: [number, number]
|
||||||
) {
|
) {
|
||||||
// convert to [startLine, startColumn, endLine, endColumn]
|
// convert to [startLine, startColumn, endLine, endColumn]
|
||||||
@ -933,7 +954,19 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 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.
|
// editor.
|
||||||
updateEditorValues();
|
const { editor } = data;
|
||||||
|
|
||||||
|
const hasChangedContents = updateEditorValues();
|
||||||
|
if (hasChangedContents && isProject()) {
|
||||||
|
initializeProjectFeatures();
|
||||||
|
updateDescriptionZone();
|
||||||
|
updateOutputZone();
|
||||||
|
}
|
||||||
|
|
||||||
|
editor?.focus();
|
||||||
|
if (isProject() && editor) {
|
||||||
|
showEditableRegion(editor);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [props.challengeFiles]);
|
}, [props.challengeFiles]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -215,15 +215,24 @@ export const reducer = handleActions(
|
|||||||
state,
|
state,
|
||||||
{ payload: { fileKey, editorValue, editableRegionBoundaries } }
|
{ payload: { fileKey, editorValue, editableRegionBoundaries } }
|
||||||
) => {
|
) => {
|
||||||
|
const updates = {};
|
||||||
|
// if a given part of the payload is null, we leave that part of the state
|
||||||
|
// unchanged
|
||||||
|
if (editableRegionBoundaries !== null)
|
||||||
|
updates.editableRegionBoundaries = editableRegionBoundaries;
|
||||||
|
if (editorValue !== null) updates.contents = editorValue;
|
||||||
|
if (editableRegionBoundaries !== null && editorValue !== null)
|
||||||
|
updates.editableContents = getLines(
|
||||||
|
editorValue,
|
||||||
|
editableRegionBoundaries
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
challengeFiles: [
|
challengeFiles: [
|
||||||
...state.challengeFiles.filter(x => x.fileKey !== fileKey),
|
...state.challengeFiles.filter(x => x.fileKey !== fileKey),
|
||||||
{
|
{
|
||||||
...state.challengeFiles.find(x => x.fileKey === fileKey),
|
...state.challengeFiles.find(x => x.fileKey === fileKey),
|
||||||
contents: editorValue,
|
...updates
|
||||||
editableContents: getLines(editorValue, editableRegionBoundaries),
|
|
||||||
editableRegionBoundaries
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@ -268,21 +277,16 @@ export const reducer = handleActions(
|
|||||||
challengeMeta: { ...payload }
|
challengeMeta: { ...payload }
|
||||||
}),
|
}),
|
||||||
[actionTypes.resetChallenge]: state => {
|
[actionTypes.resetChallenge]: state => {
|
||||||
const challengeFilesReset = [
|
const challengeFilesReset = state.challengeFiles.map(challengeFile => ({
|
||||||
...state.challengeFiles.reduce(
|
...challengeFile,
|
||||||
(challengeFiles, challengeFile) => ({
|
contents: challengeFile.seed.slice(),
|
||||||
...challengeFiles,
|
editableContents: getLines(
|
||||||
...challengeFile,
|
challengeFile.seed,
|
||||||
contents: challengeFile.seed.slice(),
|
challengeFile.seedEditableRegionBoundaries
|
||||||
editableContents: getLines(
|
),
|
||||||
challengeFile.seed,
|
editableRegionBoundaries:
|
||||||
challengeFile.seedEditableRegionBoundaries
|
challengeFile.seedEditableRegionBoundaries.slice()
|
||||||
),
|
}));
|
||||||
editableRegionBoundaries: challengeFile.seedEditableRegionBoundaries
|
|
||||||
}),
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
];
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentTab: 2,
|
currentTab: 2,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
import { toSortedArray } from '../../../../../utils/sort-files';
|
import { sortChallengeFiles } from '../../../../../utils/sort-challengefiles';
|
||||||
|
|
||||||
export function getTargetEditor(challengeFiles) {
|
export function getTargetEditor(challengeFiles) {
|
||||||
if (isEmpty(challengeFiles)) return null;
|
if (isEmpty(challengeFiles)) return null;
|
||||||
@ -9,6 +9,6 @@ export function getTargetEditor(challengeFiles) {
|
|||||||
)?.fileKey;
|
)?.fileKey;
|
||||||
|
|
||||||
// fallback for when there is no editable region.
|
// fallback for when there is no editable region.
|
||||||
return targetEditor || toSortedArray(challengeFiles)[0].fileKey;
|
return targetEditor || sortChallengeFiles(challengeFiles)[0].fileKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,7 +43,7 @@ const testEvaluator =
|
|||||||
const { getLines } = require('../../utils/get-lines');
|
const { getLines } = require('../../utils/get-lines');
|
||||||
const { isAuditedCert } = require('../../utils/is-audited');
|
const { isAuditedCert } = require('../../utils/is-audited');
|
||||||
|
|
||||||
const { toSortedArray } = require('../../utils/sort-files');
|
const { sortChallengeFiles } = require('../../utils/sort-challengefiles');
|
||||||
const {
|
const {
|
||||||
getChallengesForLang,
|
getChallengesForLang,
|
||||||
getMetaForBlock,
|
getMetaForBlock,
|
||||||
@ -471,7 +471,7 @@ ${inspect(commentMap)}
|
|||||||
// TODO: the no-solution filtering is a little convoluted:
|
// TODO: the no-solution filtering is a little convoluted:
|
||||||
const noSolution = new RegExp('// solution required');
|
const noSolution = new RegExp('// solution required');
|
||||||
|
|
||||||
const solutionsAsArrays = solutions.map(toSortedArray);
|
const solutionsAsArrays = solutions.map(sortChallengeFiles);
|
||||||
|
|
||||||
const filteredSolutions = solutionsAsArrays.filter(solution => {
|
const filteredSolutions = solutionsAsArrays.filter(solution => {
|
||||||
return !isEmpty(
|
return !isEmpty(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
exports.toSortedArray = function toSortedArray(challengeFiles) {
|
exports.sortChallengeFiles = function sortChallengeFiles(challengeFiles) {
|
||||||
const xs = challengeFiles;
|
const xs = challengeFiles.slice();
|
||||||
// TODO: refactor this to use an ext array ['html', 'js', 'css'] and loop over
|
// TODO: refactor this to use an ext array ['html', 'js', 'css'] and loop over
|
||||||
// that.
|
// that.
|
||||||
xs.sort((a, b) => {
|
xs.sort((a, b) => {
|
@ -1,21 +1,21 @@
|
|||||||
const { challengeFiles } = require('./__fixtures__/challenges');
|
const { challengeFiles } = require('./__fixtures__/challenges');
|
||||||
const { toSortedArray } = require('./sort-files');
|
const { sortChallengeFiles } = require('./sort-challengefiles');
|
||||||
|
|
||||||
describe('sort-files', () => {
|
describe('sort-files', () => {
|
||||||
describe('toSortedArray', () => {
|
describe('sortChallengeFiles', () => {
|
||||||
it('should return an array', () => {
|
it('should return an array', () => {
|
||||||
const sorted = toSortedArray(challengeFiles);
|
const sorted = sortChallengeFiles(challengeFiles);
|
||||||
expect(Array.isArray(sorted)).toBe(true);
|
expect(Array.isArray(sorted)).toBe(true);
|
||||||
});
|
});
|
||||||
it('should not modify the challenges', () => {
|
it('should not modify the challenges', () => {
|
||||||
const sorted = toSortedArray(challengeFiles);
|
const sorted = sortChallengeFiles(challengeFiles);
|
||||||
const expected = challengeFiles;
|
const expected = challengeFiles;
|
||||||
expect(sorted).toEqual(expect.arrayContaining(expected));
|
expect(sorted).toEqual(expect.arrayContaining(expected));
|
||||||
expect(sorted.length).toEqual(expected.length);
|
expect(sorted.length).toEqual(expected.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sort the objects into html, css, jsx, js order', () => {
|
it('should sort the objects into html, css, jsx, js order', () => {
|
||||||
const sorted = challengeFiles;
|
const sorted = sortChallengeFiles(challengeFiles);
|
||||||
const sortedKeys = sorted.map(({ fileKey }) => fileKey);
|
const sortedKeys = sorted.map(({ fileKey }) => fileKey);
|
||||||
const expected = ['indexhtml', 'indexcss', 'indexjsx', 'indexjs'];
|
const expected = ['indexhtml', 'indexcss', 'indexjsx', 'indexjs'];
|
||||||
expect(sortedKeys).toStrictEqual(expected);
|
expect(sortedKeys).toStrictEqual(expected);
|
Reference in New Issue
Block a user