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; contents: string; description: string; dimensions: DimensionsType; editorRef: MutableRefObject; 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 = (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 = useRef(null); const dataRef = useRef({ 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 = '✅ 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 ( }> ); }; Editor.displayName = 'Editor'; export default connect(mapStateToProps, mapDispatchToProps)(Editor);