Files
freeCodeCamp/client/src/templates/Challenges/classic/editor.tsx
Sem Bauke eccc642d83 fix(client): store accessibility for screenreaders (#42996)
* fix: store accessibility for screenreaders

* Apply suggestions from code review

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>

* fix: remove old comments

* feat: at short cut announcement when running test

* feat: announce that accesibility mode is turned on

* fix: use Redux values

* Apply suggestions from code review

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>

* Apply suggestions from Oliver

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* fix: "rip out redux stuff" and use store instead

* Apply suggestions from code review

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* Apply suggestions from code review

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* fix: old use of props

* Update client/src/templates/Challenges/classic/editor.tsx

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
2021-08-06 14:17:17 +02:00

1156 lines
37 KiB
TypeScript

import Loadable from '@loadable/component';
// eslint-disable-next-line import/no-duplicates
import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api';
import type {
IRange,
editor,
Range as RangeType
// eslint-disable-next-line import/no-duplicates
} from 'monaco-editor/esm/vs/editor/editor.api';
import React, {
useEffect,
Suspense,
RefObject,
MutableRefObject,
useRef
} from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import store from 'store';
import { Loader } from '../../../components/helpers';
import { userSelector, isDonationModalOpenSelector } from '../../../redux';
import {
ChallengeFileType,
DimensionsType,
ExtTypes,
FileKeyTypes,
ResizePropsType,
TestType
} from '../../../redux/prop-types';
import {
canFocusEditorSelector,
consoleOutputSelector,
executeChallenge,
saveEditorContent,
setEditorFocusability,
updateFile,
challengeTestsSelector,
submitChallenge
} from '../redux';
import './editor.css';
const MonacoEditor = Loadable(() => import('react-monaco-editor'));
interface EditorProps {
canFocus: boolean;
challengeFiles: ChallengeFileType;
containerRef: RefObject<HTMLElement>;
contents: string;
description: string;
dimensions: DimensionsType;
editorRef: MutableRefObject<editor.IStandaloneCodeEditor>;
executeChallenge: (isShouldCompletionModalOpen?: boolean) => void;
ext: ExtTypes;
fileKey: FileKeyTypes;
initialEditorContent: string;
initialExt: string;
output: string[];
resizeProps: ResizePropsType;
saveEditorContent: () => void;
setEditorFocusability: (isFocusable: boolean) => void;
submitChallenge: () => void;
tests: TestType[];
theme: string;
title: string;
updateFile: (objest: {
key: FileKeyTypes;
editorValue: string;
editableRegionBoundaries: number[] | null;
}) => void;
}
interface EditorProperties {
editor?: editor.IStandaloneCodeEditor;
model?: editor.ITextModel;
viewZoneId: string;
startEditDecId: string;
endEditDecId: string;
insideEditDecId: string;
viewZoneHeight: number;
outputZoneHeight: number;
outputZoneId: string;
descriptionNode?: HTMLDivElement;
outputNode?: HTMLDivElement;
overlayWidget?: editor.IOverlayWidget;
outputWidget?: editor.IOverlayWidget;
}
interface EditorPropertyStore {
indexcss: EditorProperties;
indexhtml: EditorProperties;
indexjs: EditorProperties;
indexjsx: EditorProperties;
}
const mapStateToProps = createSelector(
canFocusEditorSelector,
consoleOutputSelector,
isDonationModalOpenSelector,
userSelector,
challengeTestsSelector,
(
canFocus: boolean,
output: string[],
open,
{ theme = 'default' }: { theme: string },
tests: [{ text: string; testString: string }]
) => ({
canFocus: open ? false : canFocus,
output,
theme,
tests
})
);
// type ActionDispatchGeneric<P, T> = (payload: P) => ({type: T, payload: P});
const mapDispatchToProps = {
executeChallenge,
saveEditorContent,
setEditorFocusability,
updateFile,
submitChallenge
};
const modeMap = {
css: 'css',
html: 'html',
js: 'javascript',
jsx: 'javascript'
};
let monacoThemesDefined = false;
const defineMonacoThemes = (monaco: typeof monacoEditor) => {
if (monacoThemesDefined) {
return;
}
monacoThemesDefined = true;
const yellowColor = 'FFFF00';
const lightBlueColor = '9CDCFE';
const darkBlueColor = '00107E';
monaco.editor.defineTheme('vs-dark-custom', {
base: 'vs-dark',
inherit: true,
colors: {
'editor.background': '#2a2a40'
},
rules: [
{ token: 'delimiter.js', foreground: lightBlueColor },
{ token: 'delimiter.parenthesis.js', foreground: yellowColor },
{ token: 'delimiter.array.js', foreground: yellowColor },
{ token: 'delimiter.bracket.js', foreground: yellowColor }
]
});
monaco.editor.defineTheme('vs-custom', {
base: 'vs',
inherit: true,
// TODO: Use actual color from style-guide
colors: {
'editor.background': '#fff'
},
rules: [{ token: 'identifier.js', foreground: darkBlueColor }]
});
};
const toStartOfLine = (range: RangeType) => {
return range.setStartPosition(range.startLineNumber, 1);
};
const toLastLine = (range: RangeType) => {
return range.setStartPosition(range.endLineNumber, 1);
};
// TODO: properly initialise data with values not null
const initialData: EditorProperties = {
viewZoneId: '',
startEditDecId: '',
endEditDecId: '',
insideEditDecId: '',
viewZoneHeight: 0,
outputZoneId: '',
outputZoneHeight: 0
};
const Editor = (props: EditorProps): JSX.Element => {
const { editorRef, fileKey } = props;
// These refs are used during initialisation of the editor as well as by
// callbacks. Since they have to be initialised before editorWillMount and
// editorDidMount are called, we cannot use useState. Reason being that will
// only take effect during the next render, which is too late. We could use
// plain objects here, but useRef is shared between instances, so avoids
// unecessary object creation.
const monacoRef: MutableRefObject<typeof monacoEditor | null> =
useRef<typeof monacoEditor>(null);
const dataRef = useRef<EditorPropertyStore>({
indexcss: { ...initialData },
indexhtml: { ...initialData },
indexjs: { ...initialData },
indexjsx: { ...initialData }
});
const data = dataRef.current[fileKey];
// 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].
// NOTE: the ARIA state is controlled by fileKey, so changes to it must
// trigger a re-render. Hence state:
const options: editor.IStandaloneEditorConstructionOptions = {
fontSize: 18,
scrollBeyondLastLine: false,
selectionHighlight: false,
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
renderIndentGuides: false,
minimap: {
enabled: false
},
selectOnLineNumbers: true,
wordWrap: 'on',
scrollbar: {
horizontal: 'hidden',
vertical: 'visible',
verticalHasArrows: false,
useShadows: false,
verticalScrollbarSize: 5
},
parameterHints: {
enabled: false
},
tabSize: 2,
dragAndDrop: true,
lightbulb: {
enabled: false
},
quickSuggestions: false,
suggestOnTriggerCharacters: false
};
const getEditableRegion = () => {
const { challengeFiles, fileKey } = props;
const edRegBounds = challengeFiles[fileKey]?.editableRegionBoundaries;
return edRegBounds ? [...edRegBounds] : [];
};
const editorWillMount = (monaco: typeof monacoEditor) => {
const { challengeFiles, fileKey } = props;
monacoRef.current = monaco;
defineMonacoThemes(monaco);
// 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
// swap and reuse models, we have to create our own models to prevent
// disposal.
const model =
data.model ||
monaco.editor.createModel(
challengeFiles[fileKey]?.contents ?? '',
modeMap[challengeFiles[fileKey]?.ext ?? 'html']
);
data.model = model;
const editableRegion = getEditableRegion();
if (editableRegion.length === 2) decorateForbiddenRanges(editableRegion);
// TODO: do we need to return this?
return { model };
};
// Updates the model if the contents has changed. This is only necessary for
// changes coming from outside the editor (such as code resets).
const updateEditorValues = () => {
const { challengeFiles, fileKey } = props;
const { model } = dataRef.current[fileKey];
const newContents = challengeFiles[fileKey]?.contents;
if (model?.getValue() !== newContents) {
model?.setValue(newContents ?? '');
}
};
const editorDidMount = (
editor: editor.IStandaloneCodeEditor,
monaco: typeof monacoEditor
) => {
// TODO this should *probably* be set on focus
editorRef.current = editor;
data.editor = editor;
const storedAccessibilityMode = () => {
const accessibility = store.get('accessibilityMode') as boolean;
if (!accessibility) {
store.set('accessibilityMode', false);
}
// Only able to set the arialabel when accessibility mode is set to true
// Otherwise it gets overwritten by the monaco default aria-label
if (accessibility) {
editor.updateOptions({
ariaLabel:
'Accessibility mode set to true. Press Ctrl+e to disable or press Alt+F1 for more options'
});
}
return accessibility;
};
const accessibilityMode = storedAccessibilityMode();
editor.updateOptions({
accessibilitySupport: accessibilityMode ? 'on' : 'auto'
});
// Users who are using screen readers should not have to move focus from
// the editor to the description every time they open a challenge.
if (props.canFocus && !accessibilityMode) {
// TODO: only one Editor should be calling for focus at once.
editor.focus();
} else focusOnHotkeys();
// Removes keybind for intellisense
// Private method - hopefully changes with future version
// ref: https://github.com/microsoft/monaco-editor/issues/102
/* eslint-disable */
// @ts-ignore
editor._standaloneKeybindingService.addDynamicKeybinding(
'-editor.action.triggerSuggest',
null,
() => {}
);
/* eslint-disable */
editor.addAction({
id: 'execute-challenge',
label: 'Run tests',
/* eslint-disable no-bitwise */
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
// TODO: Discuss with Ahmad what should pop-up when a challenge is completed
run: () => props.executeChallenge(true)
});
editor.addAction({
id: 'leave-editor',
label: 'Leave editor',
keybindings: [monaco.KeyCode.Escape],
run: () => {
focusOnHotkeys();
props.setEditorFocusability(false);
}
});
editor.addAction({
id: 'save-editor-content',
label: 'Save editor content to localStorage',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S],
run: props.saveEditorContent
});
editor.addAction({
id: 'toggle-accessibility',
label: 'Toggle Accessibility Mode',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_E],
run: () => {
const currentAccessibility = storedAccessibilityMode();
store.set('accessibilityMode', !currentAccessibility);
editor.updateOptions({
accessibilitySupport: storedAccessibilityMode() ? 'on' : 'auto',
});
}
});
editor.onDidFocusEditorWidget(() => props.setEditorFocusability(true));
const editableBoundaries = getEditableRegion();
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 domNode = createDescription(editor);
const outputNode = createOutputNode(editor);
const overlayWidget = createWidget(
'my.overlay.widget',
domNode,
getViewZoneTop
);
data.overlayWidget = overlayWidget;
const outputWidget = createWidget(
'my.output.widget',
outputNode,
getOutputZoneTop
);
data.outputWidget = outputWidget;
editor.addOverlayWidget(overlayWidget);
// TODO: order of insertion into the DOM probably matters, revisit once
// the tabs have been fixed!
editor.addOverlayWidget(outputWidget);
editor.changeViewZones(viewZoneCallback);
editor.changeViewZones(outputZoneCallback);
editor.onDidScrollChange(() => {
editor.layoutOverlayWidget(overlayWidget);
editor.layoutOverlayWidget(outputWidget);
});
showEditableRegion(editableBoundaries);
}
};
const viewZoneCallback = (changeAccessor: editor.IViewZoneChangeAccessor) => {
const editor = data.editor;
if (!editor) return;
// 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 = createDescription(editor);
// make sure the overlayWidget has resized before using it to set the height
domNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
// TODO: set via onComputedHeight?
data.viewZoneHeight = domNode.offsetHeight;
const 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: getLineAfterViewZone() - 1,
heightInPx: domNode.offsetHeight,
domNode: background,
onComputedHeight: () =>
data.overlayWidget && editor.layoutOverlayWidget(data.overlayWidget)
};
data.viewZoneId = changeAccessor.addZone(viewZone);
};
// TODO: this is basically the same as viewZoneCallback, so DRY them out.
const outputZoneCallback = (
changeAccessor: editor.IViewZoneChangeAccessor
) => {
const editor = data.editor;
if (!editor) return;
// 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 = createOutputNode(editor);
// make sure the overlayWidget has resized before using it to set the height
outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
// TODO: set via onComputedHeight?
data.outputZoneHeight = outputNode.offsetHeight;
const 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: getLineAfterEditableRegion() - 1,
heightInPx: outputNode.offsetHeight,
domNode: background,
onComputedHeight: () =>
data.outputWidget && editor.layoutOverlayWidget(data.outputWidget)
};
data.outputZoneId = changeAccessor.addZone(viewZone);
};
function createDescription(editor: editor.IStandaloneCodeEditor) {
if (data.descriptionNode) return data.descriptionNode;
const { description, title } = props;
const jawHeading = document.createElement('h3');
jawHeading.innerText = title;
// TODO: var was used here. Should it?
const domNode = document.createElement('div');
const desc = document.createElement('div');
const descContainer = document.createElement('div');
descContainer.classList.add('description-container');
domNode.classList.add('editor-upper-jaw');
domNode.appendChild(descContainer);
descContainer.appendChild(jawHeading);
descContainer.appendChild(desc);
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';
domNode.setAttribute('aria-hidden', 'true');
// domNode.style.background = 'lightYellow';
domNode.style.left = `${editor.getLayoutInfo().contentLeft}px`;
domNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
domNode.style.top = getViewZoneTop();
data.descriptionNode = domNode;
return domNode;
}
function createOutputNode(editor: editor.IStandaloneCodeEditor) {
if (data.outputNode) return data.outputNode;
const outputNode = document.createElement('div');
const statusNode = document.createElement('div');
const hintNode = document.createElement('div');
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');
const 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 } = props;
executeChallenge();
};
// TODO: does it?
// The z-index needs increasing as ViewZones default to below the lines.
outputNode.style.zIndex = '10';
outputNode.setAttribute('aria-hidden', 'true');
outputNode.style.left = `${editor.getLayoutInfo().contentLeft}px`;
outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`;
outputNode.style.top = getOutputZoneTop();
data.outputNode = outputNode;
return outputNode;
}
function focusOnHotkeys() {
const currContainerRef = props.containerRef.current;
if (currContainerRef) {
currContainerRef.focus();
}
}
const onChange = (editorValue: string) => {
const { updateFile } = props;
// TODO: use fileKey everywhere?
const { fileKey: key } = props;
// TODO: now that we have getCurrentEditableRegion, should the overlays
// 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
// has changed or if content is dragged between regions)
const editableRegion = getCurrentEditableRegion();
const editableRegionBoundaries = editableRegion && [
editableRegion.startLineNumber - 1,
editableRegion.endLineNumber + 1
];
updateFile({ key, editorValue, editableRegionBoundaries });
};
function showEditableRegion(editableBoundaries: number[]) {
if (editableBoundaries.length !== 2) return;
const editor = data.editor;
if (!editor) return;
// 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: editableBoundaries[0] + 1,
column: 1
});
editor.revealLinesInCenter(editableBoundaries[0], editableBoundaries[1]);
// }
}
function highlightLines(
stickiness: number,
target: editor.ITextModel,
range: IRange,
oldIds: string[] = []
) {
const lineDecoration = {
range,
options: {
isWholeLine: true,
linesDecorationsClassName: 'myLineDecoration',
className: 'do-not-edit',
stickiness
}
};
return target.deltaDecorations(oldIds, [lineDecoration]);
}
function highlightEditableLines(
stickiness: number,
target: editor.ITextModel,
range: IRange,
oldIds: string[] = []
) {
const lineDecoration = {
range,
options: {
isWholeLine: true,
linesDecorationsClassName: 'myEditableLineDecoration',
className: 'do-not-edit',
stickiness
}
};
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]);
}
// NOTE: this is where the view zone *should* be, not necessarily were it
// currently is. (see getLineAfterViewZone)
// TODO: DRY this and getOutputZoneTop out.
function getViewZoneTop() {
const editor = data.editor;
const heightDelta = data.viewZoneHeight;
if (editor) {
const top = `${
editor.getTopForLineNumber(getLineAfterViewZone()) -
heightDelta -
editor.getScrollTop()
}px`;
return top;
}
return '0';
}
function getOutputZoneTop() {
const editor = data.editor;
const heightDelta = data.outputZoneHeight;
if (editor) {
const top = `${
editor.getTopForLineNumber(getLineAfterEditableRegion()) -
heightDelta -
editor.getScrollTop()
}px`;
return top;
}
return '0';
}
// It's not possible to directly access the current view zone so we track
// the region it should cover instead.
// TODO: DRY
function getLineAfterViewZone() {
// TODO: abstract away the data, ids etc.
const range = data.model?.getDecorationRange(data.startEditDecId);
// if the first decoration is missing, this implies the region reaches the
// start of the editor.
return range ? range.endLineNumber + 1 : 1;
}
function getLineAfterEditableRegion() {
// TODO: handle the case that the editable region reaches the bottom of the
// editor
return (
data.model?.getDecorationRange(data.endEditDecId)?.startLineNumber ?? 1
);
}
const translateRange = (range: IRange, lineDelta: number) => {
const iRange = {
...range,
startLineNumber: range.startLineNumber + lineDelta,
endLineNumber: range.endLineNumber + lineDelta
};
return monacoRef.current?.Range.lift(iRange);
};
// TODO: TESTS!
// Make 100% sure this is inclusive.
// TODO: pass around monacoRef.current instead of using the global one?
const getLinesBetweenRanges = (
firstRange: RangeType,
secondRange: RangeType
) => {
const startRange = translateRange(toLastLine(firstRange), 1);
const endRange = translateRange(
toStartOfLine(secondRange),
-1
)?.collapseToStart();
return {
startLineNumber: startRange?.startLineNumber ?? 1,
endLineNumber: endRange?.endLineNumber ?? 2
};
};
const getCurrentEditableRegion = () => {
const monaco = monacoRef.current;
const { model, startEditDecId, endEditDecId } = data;
// TODO: this is a little low-level, but we should bail if there is no
// editable region defined.
// NOTE: if a decoration is missing, there is still an editable region - it
// just extends to the edge of the editor. However, no decorations means no
// editable region.
if ((!startEditDecId && !endEditDecId) || !model || !monaco) {
return null;
} else {
const firstRange = startEditDecId
? model.getDecorationRange(startEditDecId)
: getStartOfEditor();
// TODO: handle the case that the editable region reaches the bottom of the
// editor
const secondRange = model.getDecorationRange(endEditDecId);
if (firstRange && secondRange) {
const { startLineNumber, endLineNumber } = getLinesBetweenRanges(
firstRange,
secondRange
);
// getValueInRange includes column x if
// startColumnNumber <= x < endColumnNumber
// so we add 1 here
const endColumn = model.getLineLength(endLineNumber) + 1;
return new monaco.Range(startLineNumber, 1, endLineNumber, endColumn);
}
return null;
}
};
// TODO: do this once after _monaco has been created.
const getStartOfEditor = () =>
monacoRef.current?.Range.lift({
startLineNumber: 1,
endLineNumber: 1,
startColumn: 1,
endColumn: 1
});
function decorateForbiddenRanges(editableRegion: number[]) {
const { model } = data;
const monaco = monacoRef.current;
if (!model || !monaco) return;
const forbiddenRanges: [number, number][] = [
[0, editableRegion[0]],
[editableRegion[1], model.getLineCount()]
];
const ranges = forbiddenRanges.map(positions => {
return positionsToRange(model, monaco, positions);
});
const editableRange = positionsToRange(model, monaco, [
editableRegion[0] + 1,
editableRegion[1] - 1
]);
data.insideEditDecId = highlightEditableLines(
monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
model,
editableRange
)[0];
// if the forbidden range includes the top of the editor
// we simply don't add those decorations
if (forbiddenRanges[0][1] > 0) {
// the first range should expand at the top
// TODO: Unsure what this should be - returns an array, so I added [0] @ojeytonwilliams
data.startEditDecId = highlightLines(
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
model,
ranges[0]
)[0];
highlightText(
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
model,
ranges[0]
);
}
// TODO: handle the case the region covers the bottom of the editor
// the second range should expand at the bottom
data.endEditDecId = highlightLines(
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingAfter,
model,
ranges[1]
)[0];
highlightText(
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingAfter,
model,
ranges[1]
);
// 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 getNewLineRanges(event: editor.IModelContentChangedEvent) {
const newLines = event.changes.filter(
({ text }) => text[0] === event.eol
);
return newLines.map(({ range }) => range);
}
// TODO refactor this mess
// TODO this listener needs to be replaced on reset.
model.onDidChangeContent(e => {
// 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
// and then ctrl + z twice, it would realise they've removed their
// edits. However, what if they made a warned edit, then a normal
// edit, then a warned one. Could it track that they need to make 3
// undos?
const newLineRanges = getNewLineRanges(e).map(range => {
if (monaco) {
return toStartOfLine(monaco.Range.lift(range));
}
});
const deletedLine = getDeletedLine(e);
const deletedRange = {
startLineNumber: deletedLine,
endLineNumber: deletedLine,
startColumn: 1,
endColumn: 1
};
if (e.isUndoing) {
// TODO: can we be more targeted? Only update when they could get out of
// sync
updateViewZone();
updateOutputZone();
return;
}
const warnUser = (id: string) => {
const range = model.getDecorationRange(id);
if (range) {
const coveringRange = toStartOfLine(range);
e.changes.forEach(({ range }) => {
if (monaco.Range.areIntersectingOrTouching(coveringRange, range)) {
console.log('OVERLAP!');
}
});
}
};
// 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) {
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 handleDescriptionZoneChange = () => {
if (newLineRanges.length > 0 || deletedLine > 0) {
updateViewZone();
}
};
// Stops the greyed out region from covering the editable region. Does not
// change the font decoration.
const preventOverlap = (
id: string,
stickiness: number,
highlightFunction: typeof highlightLines
) => {
// 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
// NOTE: any change in the decoration has already happened by this point
// so this covers the *new* decoration range.
const range = model.getDecorationRange(id);
if (!range) {
return id;
}
const coveringRange = toStartOfLine(range);
const oldStartOfRange = translateRange(
coveringRange.collapseToStart(),
1
);
const newCoveringRange = coveringRange.setStartPosition(
oldStartOfRange?.startLineNumber ?? 1,
1
);
// TODO: this triggers both when you delete the first line of the
// decoration AND the second. To see this, consider a region on line 5
// If you delete 5, then the new start is 4 and the computed start is 5
// so they match.
// If you delete 6, then the start of the region stays at 5, so the
// computed start is 6 and they still match.
// Is there a way to tell these cases apart?
// This means that if you delete the second line it actually removes the
// grey background from the first line.
if (oldStartOfRange) {
const touchingDeleted = monaco.Range.areIntersectingOrTouching(
deletedRange,
oldStartOfRange
);
if (touchingDeleted) {
// TODO: if they undo this should be reversed
const decorations = highlightFunction(
stickiness,
model,
newCoveringRange,
[id]
);
updateOutputZone();
return decorations[0];
} else {
return id;
}
}
return id;
};
// we only need to handle the special case of the second region being
// pulled up, the first region already behaves correctly.
data.endEditDecId = preventOverlap(
data.endEditDecId,
monaco.editor.TrackedRangeStickiness.GrowsOnlyWhenTypingBefore,
highlightLines
);
data.insideEditDecId = preventOverlap(
data.insideEditDecId,
monaco.editor.TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
highlightEditableLines
);
// TODO: do the same for the description widget
// this has to be handle differently, because we care about the END
// of the zone, not the START
// if the editable region includes the first line, the first decoration
// will be missing.
if (data.startEditDecId) {
handleDescriptionZoneChange();
warnUser(data.startEditDecId);
}
handleHintsZoneChange();
if (data.endEditDecId) {
warnUser(data.endEditDecId);
}
});
}
// creates a range covering all the lines in 'positions'
// NOTE: positions is an array of [startLine, endLine]
function positionsToRange(
model: editor.ITextModel,
monaco: typeof monacoEditor,
[start, end]: [number, number]
) {
// convert to [startLine, startColumn, endLine, endColumn]
const range = new monaco.Range(start, 1, end, 1);
// Protect against ranges that extend outside the editor
const startLineNumber = Math.max(1, range.startLineNumber);
const endLineNumber = Math.min(model.getLineCount(), range.endLineNumber);
const endColumnText = model.getLineContent(endLineNumber);
// NOTE: the end column is incremented by 2 so that the dangerous range
// extends far enough to capture new text added to the end.
// NOTE: according to the spec, it should only need to be +1, but in
// practice that's not enough.
return range
.setStartPosition(startLineNumber, 1)
.setEndPosition(range.endLineNumber, endColumnText.length + 2);
}
useEffect(() => {
// If a challenge is reset, it needs to communicate that change to the
// editor.
updateEditorValues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.challengeFiles]);
useEffect(() => {
const { output, tests } = props;
const editableRegion = getEditableRegion();
if (editableRegion.length === 2) {
const challengeComplete = tests.every(test => test.pass && !test.err);
const chellengeHasErrors = tests.some(test => test.err);
const testOutput = document.getElementById('test-output');
const testStatus = document.getElementById('test-status');
if (challengeComplete) {
const testButton = document.getElementById('test-button');
if (testButton) {
testButton.innerHTML =
'Submit your code and go to next challenge (Ctrl + Enter)';
testButton.onclick = () => {
const { submitChallenge } = props;
submitChallenge();
};
}
const editableRegionDecorators = document.getElementsByClassName(
'myEditableLineDecoration'
);
if (editableRegionDecorators.length > 0) {
for (const i of editableRegionDecorators) {
i.classList.add('tests-passed');
}
}
if (testOutput && testStatus) {
testOutput.innerHTML = '';
testStatus.innerHTML = '&#9989; Step completed.';
}
} else if (chellengeHasErrors && testStatus && testOutput) {
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:"
];
testStatus.innerHTML = `✖️ ${
wordsArray[Math.floor(Math.random() * wordsArray.length)]
}`;
testOutput.innerHTML = `${output[1]}`;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.tests]);
useEffect(() => {
const { output } = props;
// TODO: do we need this condition? What happens if the ref is empty?
if (data.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 (output) {
// 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
if (data.startEditDecId || data.endEditDecId) {
updateOutputZone();
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.output]);
useEffect(() => {
const editor = data.editor;
editor?.layout();
if (data.startEditDecId) {
updateViewZone();
updateOutputZone();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.dimensions]);
// TODO: DRY (there's going to be a lot of that)
function updateOutputZone() {
const editor = data.editor;
editor?.changeViewZones(changeAccessor => {
changeAccessor.removeZone(data.outputZoneId);
outputZoneCallback(changeAccessor);
});
}
function updateViewZone() {
const editor = data.editor;
editor?.changeViewZones(changeAccessor => {
changeAccessor.removeZone(data.viewZoneId);
viewZoneCallback(changeAccessor);
});
}
const { theme } = props;
const editorTheme = theme === 'night' ? 'vs-dark-custom' : 'vs-custom';
return (
<Suspense fallback={<Loader timeout={600} />}>
<span className='notranslate'>
<MonacoEditor
editorDidMount={editorDidMount}
editorWillMount={editorWillMount}
onChange={onChange}
options={options}
theme={editorTheme}
/>
</span>
</Suspense>
);
};
Editor.displayName = 'Editor';
export default connect(mapStateToProps, mapDispatchToProps)(Editor);