untrusted-test-only / scripts /codeEditor.js
lychees's picture
Upload 569 files
87b3b3a
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 <a href="'
+ data['html_url'] + '" target="_blank">' + data['html_url'] + '</a>');
}
});
}
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
}