-
Notifications
You must be signed in to change notification settings - Fork 7
/
codemaven.js
477 lines (444 loc) · 17.2 KB
/
codemaven.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
// Constants
var previewUpdateDelay = 300; // In milliseconds
// Global to hold any pending update of the preview and error state
var updatePreviewTimeout;
// Globals for lesson state
var hiddenCode = "";
var currentLesson = 0;
// Put up a stern warning if the user is running IE8 (Code Maven doesn't do IE8)
if ((!window.CanvasRenderingContext2D) ||
($.browser.msie && parseInt($.browser.version, 10) <= 8)) {
// We can't create a canvas, so browser don't play that game.
$('#old-browser-warning').modal( {
maxWidth: 500,
overlayClose: true,
} );
}
// Init the editor
var editorOptions = {
mode: 'javascript',
tabSize: 2,
lineNumbers: true,
lineWrapping: true,
onCursorActivity: function() {
// Wait for a period of inactivity before updating the graphic.
clearTimeout(updatePreviewTimeout);
updatePreviewTimeout = setTimeout(updatePreview, previewUpdateDelay);
}
};
var editor =
CodeMirror.fromTextArea(document.getElementById('code'), editorOptions);
// Global to keep track if we have reported an error already
var previousErrorLine = null;
// Update the preview and the error report (if any)
function updatePreview() {
var code = editor.getValue();
var okay = JSHINT(code, {'undef': true, browser: true, curly: true, smarttabs: true, predef: predefForJSHint});
if (!okay) {
var waitTime = 3000; // In ms
// There is an error, but we don't want to show it right away. Let's give them
// time to keep typing and fix it. If there is no typing for a few seconds, then
// show the error.
var errorString = "Oops! Looks like there is a problem where you typed <i>";
errorString += JSHINT.errors[0].evidence;
errorString += "</i><br />If it helps, the error may be: <i>" + JSHINT.errors[0].reason + "</i>";
var lineNum = JSHINT.errors[0].line - 1;
if (previousErrorLine != null) {
// There is an existing error, set wait time much lower so we
// update the error string quickly
waitTime = 1;
if (previousErrorLine != lineNum) {
// This fixes an odd case where you have two errors at once,
// and one of the errors can end up staying highlighted.
// It clears the highlight from all lines (because, otherwise,
// an odd issue in CodeMirror can allow multiple lines highlighted
// if you rapidly cut-and-paste, bit of a workaround, but
// a perfectly reasonable one).
for (var i = 1; i <= editor.lineCount(); i++) {
editor.setLineClass(i, null, null);
}
}
}
clearTimeout(updatePreviewTimeout);
updatePreviewTimeout = setTimeout(function() {previousErrorLine = lineNum; $('#code-errors').html(errorString).slideDown(); editor.setLineClass(lineNum, null, "errorLine");},
waitTime);
return;
} else if (additionalSyntaxChecks(code)) {
// Quietly ignore these errors. Make we should do more?
return;
} else if (previousErrorLine != null) {
$('#code-errors').slideUp();
editor.setLineClass(previousErrorLine, null, null);
editor.refresh(); // Workaround for redraw bug in Win7 Chrome w/ graphics card
previousErrorLine = null;
}
// If there is a check whether the kid should be congratulated, run that check
var lesson = lessons[currentLesson];
if (lesson.youGotItCheck) {
if (code.search(lesson.youGotItCheck) >= 0) {
// Looks like the kid did what they were supposed to do,
// but only add the congrats if we haven't added it already
var message = $('#tutor-message').html();
if (message.search(/you did it!\s*$/i) < 0) {
changeTutorMessage(message + " Great, you did it!");
}
}
}
// Now update the preview pane to show what the code does
var canvasHeight = $('#preview').height();
var canvasHTML = '<html><body style="margin: 0px;"><canvas id=pane width=400 height=' + canvasHeight + '><\/canvas>'
canvasHTML += '<script>';
canvasHTML += hiddenCode;
var executionLimitedCode = instrumentCodeForInfiniteLoops(code);
canvasHTML += executionLimitedCode;
canvasHTML += '<\/script><\/body><\/html>';
var previewFrame = document.getElementById('preview');
var preview = previewFrame.contentDocument || previewFrame.contentWindow.document;
if (preview.stopAnimation) { preview.stopAnimation(); }
if (preview.stopExecutionMonitor) { preview.stopExecutionMonitor(); }
preview.open();
preview.write(canvasHTML);
preview.close();
}
function additionalSyntaxChecks(code) {
// The only additional code syntax check is for a dot followed by
// a newline, which creates problems in some of the lessons when a
// kid has partially typed something like "c." when trying to add
// "c.moveTo"
// Note: This is obviously a hack, and a dangerous one at that
// (really should parse the code, not use a regex), but it mostly
// works in this case (with the exception of some unusual cases,
// like multiline comments and strings). Be very careful about
// adding more checks here, and consider using the parser instead
// if you do.
var re = new RegExp(/^\s*c\.\s*$/m);
return re.test(code);
}
// Instrument the code to reduce accidental infinite loops or
// recursion. Ooo, clever! This handles a surprisingly common
// student error where they accidentally create an infinite loop.
// If this wasn't here, the result is a browser that hangs and
// one very confused student.
// Warning: This type of instrumenting does get a false positive
// on long animations eventually, but that's not a huge deal.
// Warning: This will not catch infinite loops of the form
// while (1)
// doSomething()
// because it only instruments blocks (indicated by brackets {}),
// but that is also not a huge deal.
// Note: This uses the CodeMirror parser (which we already have loaded).
// Other options are the esprima, uglify, or jshint parsers.
function instrumentCodeForInfiniteLoops(code) {
var mode = CodeMirror.getMode(editorOptions, editorOptions.mode);
var state = CodeMirror.startState(mode);
var codeLines = code.split(/[\r\n]/);
var bracketPositions = [];
var pos = 0;
for (var i = 0; i < codeLines.length; i++) {
var stream = new CodeMirror.StringStream(codeLines[i]);
while (!stream.eol()) {
// We're being a little naughtly here calling token()
// directly and accessing internal variables of
// CodeMirror's parser. This could break if these
// functions change or names change.
var style = mode.token(stream, state);
var substr = stream.string.slice(stream.start, stream.pos);
// We're looking for opening of blocks. That will be any '{'
// that is not part of a hash/object literal.
if (substr == '{') {
// The state of the parser will tell us if this is a block
if (state.lexical.info != "switch") {
// Ignore switch statements, but instrument all other blocks
if (state.lexical.prev.type == "block" ||
state.lexical.prev.type == "form") {
// We found an open of a block. Add it to our list
// so we can instrument it later.
bracketPositions.push(pos + stream.start + 1);
}
}
}
stream.start = stream.pos;
}
pos += codeLines[i].length + 1;
}
// Important that this go in reverse order as to not change the
// positions of other brackets we might need to instrument.
for (var i = bracketPositions.length - 1; i >= 0; i--) {
// Add a simple but effective stop on long running code.
code = code.slice(0, bracketPositions[i]) +
'cmRunStepCount++; if (cmRunStepCount > 1000000) {throw Error("Execution limit exceeded. Infinite loop?");}' +
code.slice(bracketPositions[i]);
}
// Init cmRunStepCount and then also clear the cmRunStepCount every 10
// seconds to keep long running but perfectly okay animations from
// eventually stopping. This isn't ideal -- complex animations
// that do too much in under 10 seconds will still get stopped,
// and some infinite loops that take longer than 10 seconds to do
// a lot of stuff will still be infinite -- but it fixes most
// cases kids hit, and there isn't an ideal solution here (as in,
// see the halting problem).
// The complexity around the clearInterval is because, when the frame is
// reloaded, it does not clear the setInterval (which is a little surprising),
// so it is important to clear it manually.
code = 'var cmRunStepCount = 0;\nvar cmExecMonId = setInterval(function() { cmRunStepCount = 0; }, 10000);\ndocument.stopExecutionMonitor = function() { clearInterval(cmExecMonId); }\n' + code;
return code;
}
setTimeout(updatePreview, previewUpdateDelay);
var currentLessonStorageName;
if (typeof currentLessonStorageNameOverride === "undefined") {
currentLessonStorageName = "codeMavenCurrentLesson";
} else {
// This is an override in the JSON config. It's there, so use it.
congrats = currentLessonStorageNameOverride;
}
// Init the lessons
if (window.localStorage && window.localStorage[currentLessonStorageName] != null) {
// Ooo, HTML5 storage is fun! Cookies aren't needed in this case, since
// the server doesn't care about this data. Anyway, load the last lesson
// this person was on, if any.
data = window.localStorage[currentLessonStorageName];
data = parseInt(data, 10);
if (!isNaN(data) && data >= 0 && data < lessons.length) {
// We're paranoid, but the data checks out, use it.
currentLesson = data;
}
}
// Special functionality to allow teachers to bookmark a particular lesson
if (window.location.hash) {
var jumpTo = window.location.hash.slice(1);
// Allow people to jump to lessons (overriding the HTML5 storage) using
// hash locations in their URL. This is useful for, for example, teachers.
for (var i = 0; i < lessons.length; i++) {
var section = lessons[i].lessonSection;
if (section && section.indexOf(jumpTo) == 0) {
currentLesson = i;
break;
}
}
// Might want to clear the URL, but leave it alone for now
// window.location.hash = "";
}
// The Lesson Sections info link requires some special set up since
// the content is generated dynamically
// Warning: There's some gotchas here, this needs to be done with the original
// lessons array (so before initCode(), which modifies that, though maybe
// it shouldn't), and also depends on none of the lesson sections having the
// same name (though it warns if they do).
var data = "";
for (var i = 0; i < lessons.length; i++) {
var nameUsed = {};
var section = lessons[i].lessonSection;
if (section) {
if (nameUsed[section]) {
console.log("Warning: Some lesson sections have the same name, that's a problem.");
} else {
var jsLambda = "$.modal.close(); jumpToLesson(" + i + "); return false;";
data += '<li><a href="#' + section + '" onclick="' + jsLambda + '">' + section + '</a>'
nameUsed[section] = 1;
}
}
}
$('#lesson-sections-list').html(data);
initCode();
initLesson();
function initCode(initLessonSection) {
// These need to be three separate loops because each may stop at
// a different point
for (var i = currentLesson; i >= 0; i--) {
if (lessons[i].code != null) {
editor.setValue(lessons[i].code);
break;
}
}
for (var i = currentLesson; i >= 0; i--) {
if (lessons[i].hiddenCode != null) {
hiddenCode = lessons[i].hiddenCode;
break;
}
}
for (var i = currentLesson; i >= 0; i--) {
if (lessons[i].lessonSection != null) {
// Copy the lessonSection from the last one
// where it was defined
lessons[currentLesson].lessonSection = lessons[i].lessonSection;
break;
}
}
for (var i = currentLesson; i >= 0; i--) {
if (lessons[i].tutorImage != null) {
// Copy the tutorImage from the last one
// where it was defined
lessons[currentLesson].tutorImage = lessons[i].tutorImage;
break;
}
}
}
function nextLesson() {
currentLesson += 1;
// Don't advance past the beginning or end
if (currentLesson >= lessons.length) {
currentLesson = lessons.length - 1;
} else if (currentLesson < 0) {
currentLesson = 0;
}
initLesson();
}
function changeTutorMessage(message) {
// New message with nice transition (plus the fancy transition
// works around a rendering bug in Google Chrome).
var tutorMessage = $('#tutor-message');
tutorMessage.stop(true).fadeTo(50, 0.5, function() {
tutorMessage.html(message);
tutorMessage.fadeTo(250, 1.0);
});
}
function changeLessonSection(name) {
var lessonSection = $('#lesson-section');
// Do nothing if the lessonSection is the same
if (lessonSection.html() == name) { return; }
// Show the name of the section of lessons with nice transition
lessonSection.stop(true).fadeTo(50, 0.5, function() {
lessonSection.html(name);
lessonSection.fadeTo(250, 1.0);
});
}
function changeTutorImage(filename) {
var currentTutorImage = $('#tutor-img');
var imgPath = imgToURL(filename);
// Do nothing if the tutor image is the same
if (currentTutorImage.attr("src") == imgPath) { return }
// We need to make sure the image is loaded, so change the
// image in this rather complicated way
var i = new Image();
// Very small chance of a race condition here because we wait for the image to load
// Deal with it by aborting if the tutorImage has changed by the time this is called
var oldTutorImage = lessons[currentLesson].tutorImage;
i.onload = function() {
if (oldTutorImage != lessons[currentLesson].tutorImage) { return }
// Show the new tutor with a nice transition
currentTutorImage.stop(true, true).fadeTo(200, 0.7, function() {
currentTutorImage.attr("src", imgPath);
currentTutorImage.fadeTo(200, 1.0);
});
};
i.src = imgPath;
}
function initLesson() {
var lesson = lessons[currentLesson];
changeTutorMessage(lesson.message);
if (lesson.lessonSection != null) {
changeLessonSection(lesson.lessonSection);
}
if (lesson.tutorImage != null) {
changeTutorImage(tutorImages[lesson.tutorImage - 1]);
}
if (lesson.hiddenCode != null) {
// It's important to set this before calling updatePreview
// below since it will be used in that code.
hiddenCode = lesson.hiddenCode;
}
if (lesson.code != null) {
editor.setValue(lesson.code);
// Move to the stop of the code whenever we change the
// code (it's a nice visual signal and some lessons assume
// the student is at the top of the code), but otherwise
// preserve the position wherever they are
editor.scrollTo(null, 0);
}
updatePreview();
if (window.localStorage) {
// Save progress
window.localStorage[currentLessonStorageName] = currentLesson;
}
// Update progress bar display
var percent = Math.floor((currentLesson + 1) * 100 / lessons.length);
$('#lesson-progress').attr('value', percent);
$('#lesson-progress').attr('title', percent + "% completed");
}
// Clicking on the maven or her speech advances to the next lesson
$('#tutor-talk').mousedown(
function(evt) {
// Allow clickable anchor tags in the tutor messages. Done
// this way for a reason, this isn't as simple as always
// returning true because nextLesson() changes the message,
// destroying the anchor tag.
if ($(evt.target).is("#tutor-message a")) { return true; }
nextLesson();
}
);
$('#tutor-avatar').mousedown(nextLesson);
// Reset button sets the code back to what it was at the beginning of the lesson
$('#reset-button').click(function() {
// Briefly clear the code to give visual feedback, then reset it
editor.setValue("");
updatePreview();
setTimeout(function() {initCode(); updatePreview();}, 100);
});
// Jumping to a different lesson resets the code, and briefly
// says what lesson we are on
function jumpToLesson(lessonNum) {
// Don't allow illegal values
if (lessonNum < 0) {
lessonNum = 0;
} else if (lessonNum >= lessons.length) {
lessonNum = lessons.length - 1;
}
if (currentLesson != lessonNum) {
// Only update everything if this is a change
currentLesson = lessonNum;
initCode();
initLesson();
updatePreview();
}
// ... but always show the current lesson
var lessonNum = $('#lesson-number');
lessonNum.stop(true).fadeOut(50, function() {
lessonNum.html("Lesson " + (currentLesson+1));
lessonNum.fadeIn(100).fadeOut(700);
});
}
// Back button moves back one lesson, resets the code, and briefly
// says what lesson we are on
$('#back-button').click(function() {
jumpToLesson(currentLesson - 1);
});
// Disable dragging the image (because it can be confusing to the user)
$('#tutor-avatar img').bind('dragstart', function(evt) { evt.preventDefault(); } );
// Selection is disabled in the CSS, but IE9 seems to have bugs that
// still allow it in rare cases. Here's yet another attempt to
// prevent IE9 from allowing selection of the tutor text.
$('#tutor').bind('selectstart', function(evt) { evt.preventDefault(); } );
// Enable all the info links at the bottom of the page
// We just use modal dialogs for all of them, never leave the page.
$('.info-link').click( function(evt) {
// Take the html string and convert it into an id
// (just the lower case version of the string with spaces made into dashes
var id = $(evt.target).html().toLowerCase().replace(/\s+/g, "-");
id = "#" + id;
// Open the modal dialog using the content for that id
$(id).modal({
onOpen: function(d) {
// We could make these sequential, but they look good in parallel
d.overlay.fadeIn(300);
d.container.fadeIn(700);
d.data.fadeIn(1000);
},
onClose: function(d) {
d.data.fadeOut(200);
d.container.fadeOut(200);
// $.model.close() needs to be called at the end to clean up
d.overlay.fadeOut(400, $.modal.close);
},
maxWidth: 500,
maxHeight: 700,
autoResize: true,
overlayClose: true,
});
});
function imgToURL(filename) {
return "i/" + filename;
}
// Preload the tutor images
for (var i=0; i < tutorImages.length; i++) {
(new Image()).src = imgToURL(tutorImages[i]);
}