function CodeEditor(textAreaDomID, width, height, game) { var symbols = { 'begin_line':'#BEGIN_EDITABLE#', 'end_line':'#END_EDITABLE#', 'begin_char':"#{#", 'end_char': "#}#", 'begin_properties':'#BEGIN_PROPERTIES#', 'end_properties':'#END_PROPERTIES#', 'start_start_level':'#START_OF_START_LEVEL#', 'end_start_level':'#END_OF_START_LEVEL#' }; var charLimit = 80; var properties = {}; var editableLines = []; var editableSections = {}; var lastChange = {}; var startOfStartLevel = null; var endOfStartLevel = null; this.setEndOfStartLevel = function (eosl) { endOfStartLevel = eosl; } this.setEditableLines = function (el) { editableLines = el; } this.setEditableSections = function (es) { editableSections = es; } // for debugging purposes log = function (text) { if (game._debugMode) { console.log(text); } } // preprocesses code,determines the location // of editable lines and sections, loads properties function preprocess(codeString) { editableLines = []; editableSections = {}; endOfStartLevel = null; startOfStartLevel = null; var propertiesString = ''; var lineArray = codeString.split("\n"); var inEditableBlock = false; var inPropertiesBlock = false; for (var i = 0; i < lineArray.length; i++) { var currentLine = lineArray[i]; // process properties if (currentLine.indexOf(symbols.begin_properties) === 0) { lineArray.splice(i,1); // be aware that this *mutates* the list i--; inPropertiesBlock = true; } else if (currentLine.indexOf(symbols.end_properties) === 0) { lineArray.splice(i,1); i--; inPropertiesBlock = false; } else if (inPropertiesBlock) { lineArray.splice(i,1); i--; propertiesString += currentLine; } // process editable lines and sections else if (currentLine.indexOf(symbols.begin_line) === 0) { lineArray.splice(i,1); i--; inEditableBlock = true; } else if (currentLine.indexOf(symbols.end_line) === 0) { lineArray.splice(i,1); i--; inEditableBlock = false; } // process start of startLevel() else if (currentLine.indexOf(symbols.start_start_level) === 0) { lineArray.splice(i,1); startOfStartLevel = i; i--; } // process end of startLevel() else if (currentLine.indexOf(symbols.end_start_level) === 0) { lineArray.splice(i,1); endOfStartLevel = i; i--; } // everything else else { if (inEditableBlock) { editableLines.push(i); } else { // check if there are any editable sections var sections = []; var startPoint = null; for (var j = 0; j < currentLine.length - 2; j++) { if (currentLine.slice(j,j+3) === symbols.begin_char) { currentLine = currentLine.slice(0,j) + currentLine.slice(j+3, currentLine.length); startPoint = j; } else if (currentLine.slice(j,j+3) === symbols.end_char) { currentLine = currentLine.slice(0,j) + currentLine.slice(j+3, currentLine.length); sections.push([startPoint, j]); } } if (sections.length > 0) { lineArray[i] = currentLine; editableSections[i] = sections; } } } } try { properties = JSON.parse(propertiesString); } catch (e) { properties = {}; } return lineArray.join("\n"); } var findEndOfSegment = function(line) { // Given an editable line number, returns the last line of the // given line's editable segment. if (editableLines.indexOf(line + 1) === -1) { return line; } return findEndOfSegment(line + 1); }; var shiftLinesBy = function(array, after, shiftAmount) { // Shifts all line numbers strictly after the given line by // the provided amount. return array.map(function(line) { if (line > after) { log('Shifting ' + line + ' to ' + (line + shiftAmount)); return line + shiftAmount; } return line; }); }; // enforces editing restrictions when set as the handler // for the 'beforeChange' event var enforceRestrictions = function(instance, change) { lastChange = change; var inEditableArea = function(c) { var lineNum = c.to.line; if (editableLines.indexOf(lineNum) !== -1 && editableLines.indexOf(c.from.line) !== -1) { // editable lines? return true; } else if (editableSections[lineNum]) { // this line has editable sections - are we in one of them? var sections = editableSections[lineNum]; for (var i = 0; i < sections.length; i++) { var section = sections[i]; if (c.from.ch > section[0] && c.to.ch > section[0] && c.from.ch < section[1] && c.to.ch < section[1]) { return true; } } return false; } }; log( '---Editor input (beforeChange) ---\n' + 'Kind: ' + change.origin + '\n' + 'Number of lines: ' + change.text.length + '\n' + 'From line: ' + change.from.line + '\n' + 'To line: ' + change.to.line ); if (!inEditableArea(change)) { change.cancel(); } else if (change.to.line < change.from.line || change.to.line - change.from.line + 1 > change.text.length) { // Deletion updateEditableLinesOnDeletion(change); } else { // Insert/paste // First line already editable var newLines = change.text.length - (change.to.line - change.from.line + 1); if (newLines > 0) { if (editableLines.indexOf(change.to.line) < 0) { change.cancel(); return; } // enforce 80-char limit by wrapping all lines > 80 chars var wrappedText = []; change.text.forEach(function (line) { while (line.length > charLimit) { // wrap lines at spaces if at all possible var minCutoff = charLimit - 20; var cutoff = minCutoff + line.slice(minCutoff).indexOf(" "); if (cutoff > 80) { // no suitable cutoff point found - let's get messy cutoff = 80; } wrappedText.push(line.substr(0, cutoff)); line = line.substr(cutoff); } wrappedText.push(line); }); change.text = wrappedText; // updating line count newLines = change.text.length - (change.to.line - change.from.line + 1); updateEditableLinesOnInsert(change, newLines); } else { // enforce 80-char limit by trimming the line var lineLength = instance.getLine(change.to.line).length; if (lineLength + change.text[0].length > charLimit) { var allowedLength = Math.max(charLimit - lineLength, 0); change.text[0] = change.text[0].substr(0, allowedLength); } } // modify editable sections accordingly // TODO Probably broken by multiline paste var sections = editableSections[change.to.line]; if (sections) { var delta = change.text[0].length - (change.to.ch - change.from.ch); for (var i = 0; i < sections.length; i++) { // move any section start/end points that we are to the left of if (change.to.ch < sections[i][1]) { sections[i][1] += delta; } if (change.to.ch < sections[i][0]) { sections[i][0] += delta; } } } } log(editableLines); } var updateEditableLinesOnInsert = function(change, newLines) { var lastLine = findEndOfSegment(change.to.line); // Shift editable line numbers after this segment editableLines = shiftLinesBy(editableLines, lastLine, newLines); // TODO If editable sections appear together with editable lines // in a level, multiline edit does not properly handle editable // sections. log("Appending " + newLines + " lines"); // Append new lines for (var i = lastLine + 1; i <= lastLine + newLines; i++) { editableLines.push(i); } // Update endOfStartLevel if (endOfStartLevel) { endOfStartLevel += newLines; } }; var updateEditableLinesOnDeletion = function(changeInput) { // Figure out how many lines just got removed var numRemoved = changeInput.to.line - changeInput.from.line - changeInput.text.length + 1; // Find end of segment var editableSegmentEnd = findEndOfSegment(changeInput.to.line); // Remove that many lines from its end, one by one for (var i = editableSegmentEnd; i > editableSegmentEnd - numRemoved; i--) { log('Removing\t' + i); editableLines.remove(i); } // Shift lines that came after editableLines = shiftLinesBy(editableLines, editableSegmentEnd, -numRemoved); // TODO Shift editableSections // Update endOfStartLevel if (endOfStartLevel) { endOfStartLevel -= numRemoved; } }; // beforeChange events don't pick up undo/redo // so we track them on change event var trackUndoRedo = function(instance, change) { if (change.origin === 'undo' || change.origin === 'redo') { enforceRestrictions(instance, change); } } this.initialize = function() { this.internalEditor = CodeMirror.fromTextArea(document.getElementById(textAreaDomID), { theme: 'vibrant-ink', lineNumbers: true, dragDrop: false, smartIndent: false }); this.internalEditor.setSize(width, height); // set up event handlers this.internalEditor.on("focus", function(instance) { // implements yellow box when changing focus $('.CodeMirror').addClass('focus'); $('#screen canvas').removeClass('focus'); $('#helpPane').hide(); $('#menuPane').hide(); }); this.internalEditor.on('cursorActivity', function (instance) { // fixes the cursor lag bug instance.refresh(); // automatically smart-indent if the cursor is at position 0 // and the line is empty (ignore if backspacing) if (lastChange.origin !== '+delete') { var loc = instance.getCursor(); if (loc.ch === 0 && instance.getLine(loc.line).trim() === "") { instance.indentLine(loc.line, "prev"); } } }); this.internalEditor.on('change', markEditableSections); this.internalEditor.on('change', trackUndoRedo); } // loads code into editor this.loadCode = function(codeString) { /* * logic: before setting the value of the editor to the code string, * we run it through setEditableLines and setEditableSections, which * strip our notation from the string and as a side effect build up * a data structure of editable areas */ this.internalEditor.off('beforeChange', enforceRestrictions); codeString = preprocess(codeString); this.internalEditor.setValue(codeString); this.internalEditor.on('beforeChange', enforceRestrictions); this.markUneditableLines(); this.internalEditor.refresh(); this.internalEditor.clearHistory(); }; // marks uneditable lines within editor this.markUneditableLines = function() { var instance = this.internalEditor; for (var i = 0; i < instance.lineCount(); i++) { if (editableLines.indexOf(i) === -1) { instance.addLineClass(i, 'wrap', 'disabled'); } } } // marks editable sections inside uneditable lines within editor var markEditableSections = function(instance) { $('.editableSection').removeClass('editableSection'); for (var line in editableSections) { if (editableSections.hasOwnProperty(line)) { var sections = editableSections[line]; for (var i = 0; i < sections.length; i++) { var section = sections[i]; var from = {'line': parseInt(line), 'ch': section[0]}; var to = {'line': parseInt(line), 'ch': section[1]}; instance.markText(from, to, {'className': 'editableSection'}); } } } } // returns all contents this.getCode = function (forSaving) { var lines = this.internalEditor.getValue().split('\n'); if (!forSaving && startOfStartLevel) { // insert the end of startLevel() marker at the appropriate location lines.splice(startOfStartLevel, 0, "map._startOfStartLevelReached()"); } if (!forSaving && endOfStartLevel) { // insert the end of startLevel() marker at the appropriate location lines.splice(endOfStartLevel+1, 0, "map._endOfStartLevelReached()"); } return lines.join('\n'); } // returns only the code written in editable lines and sections this.getPlayerCode = function () { var code = ''; for (var i = 0; i < this.internalEditor.lineCount(); i++) { if (editableLines && editableLines.indexOf(i) > -1) { code += this.internalEditor.getLine(i) + ' \n'; } } for (var line in editableSections) { if (editableSections.hasOwnProperty(line)) { var sections = editableSections[line]; for (var i = 0; i < sections.length; i++) { var section = sections[i]; code += this.internalEditor.getLine(line).slice(section[0], section[1]) + ' \n'; } } } return code; }; this.getProperties = function () { return properties; } this.setCode = function(code) { // make sure we're not saving the hidden START/END_OF_START_LEVEL lines code = code.split('\n').filter(function (line) { return line.indexOf('OfStartLevelReached') < 0; }).join('\n'); this.internalEditor.off('beforeChange',enforceRestrictions); this.internalEditor.setValue(code); this.internalEditor.on('beforeChange', enforceRestrictions); this.markUneditableLines(); this.internalEditor.refresh(); this.internalEditor.clearHistory(); } this.saveGoodState = function () { var lvlNum = game._currentFile ? game._currentFile : game._currentLevel; localStorage.setItem(game._getLocalKey('level' + lvlNum + '.lastGoodState'), JSON.stringify({ code: this.getCode(true), playerCode: this.getPlayerCode(), editableLines: editableLines, editableSections: editableSections, endOfStartLevel: endOfStartLevel, version: this.getProperties().version })); } this.createGist = function () { var lvlNum = game._currentLevel; var filename = 'untrusted-lvl' + lvlNum + '-solution.js'; var description = 'Solution to level ' + lvlNum + ' in Untrusted: http://alex.nisnevich.com/untrusted/'; var data = { 'files': {}, 'description': description, 'public': true }; data['files'][filename] = { 'content': this.getCode(true).replace(/\t/g, ' ') }; var t = ['372f2dad', '3edbb23c', '7c82f871', '36a67eb8', '623e8b32']; $.ajax({ 'url': 'https://api.github.com/gists', 'type': 'POST', 'data': JSON.stringify(data), 'headers': { 'Authorization': 'token ' + t.join('') }, 'success': function (data, status, xhr) { $('#savedLevelMsg').html('Level ' + lvlNum + ' solution saved at ' + data['html_url'] + ''); } }); } this.getGoodState = function (lvlNum) { return JSON.parse(localStorage.getItem(game._getLocalKey('level' + lvlNum + '.lastGoodState'))); } this.refresh = function () { this.internalEditor.refresh(); } this.focus = function () { this.internalEditor.focus(); } this.initialize(); // run initialization code }