diff --git a/client/src/client/frame-runner.js b/client/src/client/frame-runner.js index ec0433b6fd..c8a2430af2 100644 --- a/client/src/client/frame-runner.js +++ b/client/src/client/frame-runner.js @@ -5,8 +5,10 @@ window.$ = jQuery; document.__initTestFrame = initTestFrame; -async function initTestFrame(e = {}) { - const code = (e.code || '').slice(0); +async function initTestFrame(e = { code: {} }) { + const code = (e.code.contents || '').slice(); + // eslint-disable-next-line no-unused-vars + const editableContents = (e.code.editableContents || '').slice(); if (!e.getUserInput) { e.getUserInput = () => code; } diff --git a/client/src/client/workers/test-evaluator.js b/client/src/client/workers/test-evaluator.js index 130d2d51f9..4f146101ac 100644 --- a/client/src/client/workers/test-evaluator.js +++ b/client/src/client/workers/test-evaluator.js @@ -55,7 +55,9 @@ const __utils = (() => { /* Run the test if there is one. If not just evaluate the user code */ self.onmessage = async e => { /* eslint-disable no-unused-vars */ - const { code = '' } = e.data; + const code = (e.data?.code?.contents || '').slice(); + const editableContents = (e.data?.code?.editableContents || '').slice(); + const assert = chai.assert; // Fake Deep Equal dependency const DeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); diff --git a/client/src/templates/Challenges/classic/Editor.js b/client/src/templates/Challenges/classic/Editor.js index a838f324c7..5d316827ee 100644 --- a/client/src/templates/Challenges/classic/Editor.js +++ b/client/src/templates/Challenges/classic/Editor.js @@ -481,6 +481,7 @@ class Editor extends Component { outputNode.innerHTML = 'TESTS GO HERE'; + // TODO: does it? // The z-index needs increasing as ViewZones default to below the lines. outputNode.style.zIndex = '10'; @@ -508,7 +509,19 @@ class Editor extends Component { onChange = editorValue => { const { updateFile } = this.props; - updateFile({ key: this.state.fileKey, editorValue }); + // TODO: use fileKey everywhere? + const { fileKey: key } = this.state; + // 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 = this.getCurrentEditableRegion(key); + const editableRegionBoundaries = editableRegion && [ + editableRegion.startLineNumber - 1, + editableRegion.endLineNumber + 1 + ]; + updateFile({ key, editorValue, editableRegionBoundaries }); }; changeTab = newFileKey => { @@ -620,6 +633,48 @@ class Editor extends Component { ).startLineNumber; } + translateRange = (range, lineDelta) => { + const iRange = { + ...range, + startLineNumber: range.startLineNumber + lineDelta, + endLineNumber: range.endLineNumber + lineDelta + }; + return this._monaco.Range.lift(iRange); + }; + + getLinesBetweenRanges = (firstRange, secondRange) => { + const startRange = this.translateRange(toLastLine(firstRange), 1); + const endRange = this.translateRange( + toStartOfLine(secondRange), + -1 + ).collapseToStart(); + + return { + startLineNumber: startRange.startLineNumber, + endLineNumber: endRange.endLineNumber + }; + }; + + getCurrentEditableRegion = key => { + const model = this.data[key].model; + // TODO: this is a little low-level, but we should bail if there is no + // editable region defined. + if (!this.data[key].startEditDecId || !this.data[key].endEditDecId) + return null; + const firstRange = model.getDecorationRange(this.data[key].startEditDecId); + const secondRange = model.getDecorationRange(this.data[key].endEditDecId); + const { startLineNumber, endLineNumber } = this.getLinesBetweenRanges( + firstRange, + secondRange + ); + + // getValueInRange includes column x if + // startColumnNumber <= x < endColumnNumber + // so we add 1 here + const endColumn = model.getLineLength(endLineNumber) + 1; + return new this._monaco.Range(startLineNumber, 1, endLineNumber, endColumn); + }; + decorateForbiddenRanges(key, editableRegion) { const model = this.data[key].model; const forbiddenRanges = [ @@ -674,15 +729,6 @@ class Editor extends Component { return newLines.map(({ range }) => range); } - const translateRange = (range, lineDelta) => { - const iRange = { - ...range, - startLineNumber: range.startLineNumber + lineDelta, - endLineNumber: range.endLineNumber + lineDelta - }; - return this._monaco.Range.lift(iRange); - }; - // TODO refactor this mess // TODO this listener needs to be replaced on reset. model.onDidChangeContent(e => { @@ -730,9 +776,9 @@ class Editor extends Component { ).collapseToStart(); // the decoration needs adjusting if the user creates a line immediately // before the greyed out region... - const lineOneRange = translateRange(startOfZone, -2); + const lineOneRange = this.translateRange(startOfZone, -2); // or immediately after it - const lineTwoRange = translateRange(startOfZone, -1); + const lineTwoRange = this.translateRange(startOfZone, -1); for (const lineRange of newLineRanges) { const shouldMoveZone = this._monaco.Range.areIntersectingOrTouching( @@ -753,7 +799,7 @@ class Editor extends Component { ).collapseToStart(); // the decoration needs adjusting if the user creates a line immediately // before the editable region. - const lineOneRange = translateRange(endOfZone, -1); + const lineOneRange = this.translateRange(endOfZone, -1); for (const lineRange of newLineRanges) { const shouldMoveZone = this._monaco.Range.areIntersectingOrTouching( @@ -776,7 +822,7 @@ class Editor extends Component { // NOTE: any change in the decoration has already happened by this point // so this covers the *new* decoration range. const coveringRange = toStartOfLine(model.getDecorationRange(id)); - const oldStartOfRange = translateRange( + const oldStartOfRange = this.translateRange( coveringRange.collapseToStart(), 1 ); diff --git a/client/src/templates/Challenges/classic/Show.js b/client/src/templates/Challenges/classic/Show.js index d91966efa5..80f207b552 100644 --- a/client/src/templates/Challenges/classic/Show.js +++ b/client/src/templates/Challenges/classic/Show.js @@ -323,7 +323,9 @@ export default connect( mapDispatchToProps )(ShowClassic); -// TODO: handle jsx (not sure why it doesn't get an editableRegion) +// TODO: handle jsx (not sure why it doesn't get an editableRegion) EDIT: +// probably because the dummy challenge didn't include it, so Gatsby couldn't +// infer it. export const query = graphql` query ClassicChallenge($slug: String!) { challengeNode(fields: { slug: { eq: $slug } }) { diff --git a/client/src/templates/Challenges/redux/code-storage-epic.js b/client/src/templates/Challenges/redux/code-storage-epic.js index 4c7f300ca6..0cd2163f98 100644 --- a/client/src/templates/Challenges/redux/code-storage-epic.js +++ b/client/src/templates/Challenges/redux/code-storage-epic.js @@ -138,7 +138,13 @@ function loadCodeEpic(action$, state$) { ...file, contents: codeFound[file.key] ? codeFound[file.key].contents - : file.contents + : file.contents, + editableContents: codeFound[file.key] + ? codeFound[file.key].editableContents + : file.editableContents, + editableRegionBoundaries: codeFound[file.key] + ? codeFound[file.key].editableRegionBoundaries + : file.editableRegionBoundaries } }), {} diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index da70e468b1..b87971dd3f 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -116,14 +116,27 @@ export const createFiles = createAction(types.createFiles, challengeFiles => ...challengeFiles, [file.key]: { ...createPoly(file), - seed: file.contents.slice(0), - editableRegion: file.editableRegion + seed: file.contents.slice(), + editableContents: getLines( + file.contents, + file.editableRegionBoundaries + ), + seedEditableRegionBoundaries: file.editableRegionBoundaries.slice() } }), {} ) ); +// TODO: secure with tests +function getLines(contents, range) { + const lines = contents.split('\n'); + const editableLines = isEmpty(lines) + ? [] + : lines.slice(range[0], range[1] - 1); + return editableLines.join('\n'); +} + export const createQuestion = createAction(types.createQuestion); export const initTests = createAction(types.initTests); export const updateTests = createAction(types.updateTests); @@ -251,13 +264,18 @@ export const reducer = handleActions( ...state, challengeFiles: payload }), - [types.updateFile]: (state, { payload: { key, editorValue } }) => ({ + [types.updateFile]: ( + state, + { payload: { key, editorValue, editableRegionBoundaries } } + ) => ({ ...state, challengeFiles: { ...state.challengeFiles, [key]: { ...state.challengeFiles[key], - contents: editorValue + contents: editorValue, + editableContents: getLines(editorValue, editableRegionBoundaries), + editableRegionBoundaries } } }), @@ -265,7 +283,6 @@ export const reducer = handleActions( ...state, challengeFiles: payload }), - [types.initTests]: (state, { payload }) => ({ ...state, challengeTests: payload @@ -314,7 +331,11 @@ export const reducer = handleActions( [file.key]: { ...file, contents: file.seed.slice(), - editableRegion: file.editableRegion + editableContents: getLines( + file.seed, + file.seedEditableRegionBoundaries + ), + editableRegionBoundaries: file.seedEditableRegionBoundaries } }), {} diff --git a/client/src/templates/Challenges/utils/build.js b/client/src/templates/Challenges/utils/build.js index c07a0f5001..1486c58a8f 100644 --- a/client/src/templates/Challenges/utils/build.js +++ b/client/src/templates/Challenges/utils/build.js @@ -53,13 +53,15 @@ function buildSourceMap(files) { // the same name 'index'. This made the last file the only file to appear in // sources. // A better solution is to store and handle them separately. Perhaps never - // setting the name to 'index'. + // setting the name to 'index'. Use 'contents' instead? + // TODO: is file.source ever defined? return files.reduce( (sources, file) => { sources[file.name] += file.source || file.contents; + sources.editableContents += file.editableContents; return sources; }, - { index: '' } + { index: '', editableContents: '' } ); } @@ -111,7 +113,10 @@ export function getTestRunner(buildData, { proxyLogger }, document) { } function getJSTestRunner({ build, sources }, proxyLogger) { - const code = sources && 'index' in sources ? sources['index'] : ''; + const code = { + contents: sources.index, + editableContents: sources.editableContents + }; const testWorker = createWorker(testEvaluator, { terminateWorker: true }); diff --git a/client/src/templates/Challenges/utils/frame.js b/client/src/templates/Challenges/utils/frame.js index b7999472a7..e21e5f1771 100644 --- a/client/src/templates/Challenges/utils/frame.js +++ b/client/src/templates/Challenges/utils/frame.js @@ -98,7 +98,10 @@ const initTestFrame = frameReady => ctx => { const { sources, loadEnzyme } = ctx; // default for classic challenges // should not be used for modern - const code = sources && 'index' in sources ? sources['index'] : ''; + const code = { + contents: sources.index, + editableContents: sources.editableContents + }; // provide the file name and get the original source const getUserInput = fileName => toString(sources[fileName]); await ctx.document.__initTestFrame({ code, getUserInput, loadEnzyme }); diff --git a/curriculum/test/test-challenges.js b/curriculum/test/test-challenges.js index e94e1d7e3a..3fffe9fd29 100644 --- a/curriculum/test/test-challenges.js +++ b/curriculum/test/test-challenges.js @@ -432,7 +432,10 @@ async function createTestRunner(challenge, solution, buildChallenge) { required, template }); - const code = sources && 'index' in sources ? sources['index'] : ''; + const code = { + contents: sources.index, + editableContents: sources.editableContents + }; const evaluator = await (buildChallenge === buildDOMChallenge ? getContextEvaluator(build, sources, code, loadEnzyme)