mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-12 08:41:46 -05:00
663c220eaf
This heavily refactors SpellView and adds infrastructure for receiving and reporting Errors raised by the web-dev iFrame. The web-dev error system, the Aether error system, and the Ace html-worker avoid disturbing each others' errors/annotations (though currently Aether+web-dev errors won't coexist), and they clear/update their own asynchronously. Show web-dev iFrame errors as Ace annotations Add functional error banners (with poor messages) Improve error banners, don't allow duplicate Problems Refactor setAnnotations override Convert all constructor calls for Problems Add comments, clean up Clean up Don't clear things unnecessarily Clean up error message sending from iFrame Add web-dev:error schema Clarify error message attributes Refactor displaying AetherProblems Refactor displaying user problem banners Refactor onWebDevError Set ace styles on updating @problems Clean up, fix off-by-1 error Add comment Show stale web-dev errors differently Some web-dev errors are generated by "stale" code — code that's still running in the iFrame but doesn't have the player's recent changes. This shows those errors differently than if they weren't "stale", and suggests they re-run their code. Hook up web-dev event schema Destroy ignored duplicate problems Functionalize a bit of stuff Fix ProblemAlertView never loading
1358 lines
59 KiB
CoffeeScript
1358 lines
59 KiB
CoffeeScript
CocoView = require 'views/core/CocoView'
|
|
template = require 'templates/play/level/tome/spell'
|
|
{me} = require 'core/auth'
|
|
filters = require 'lib/image_filter'
|
|
Range = ace.require('ace/range').Range
|
|
UndoManager = ace.require('ace/undomanager').UndoManager
|
|
Problem = require './Problem'
|
|
SpellDebugView = require './SpellDebugView'
|
|
SpellTranslationView = require './SpellTranslationView'
|
|
SpellToolbarView = require './SpellToolbarView'
|
|
LevelComponent = require 'models/LevelComponent'
|
|
UserCodeProblem = require 'models/UserCodeProblem'
|
|
utils = require 'core/utils'
|
|
CodeLog = require 'models/CodeLog'
|
|
Autocomplete = require './editor/autocomplete'
|
|
|
|
module.exports = class SpellView extends CocoView
|
|
id: 'spell-view'
|
|
className: 'shown'
|
|
template: template
|
|
controlsEnabled: true
|
|
eventsSuppressed: true
|
|
writable: true
|
|
languagesThatUseWorkers: ['html']
|
|
|
|
keyBindings:
|
|
'default': null
|
|
'vim': 'ace/keyboard/vim'
|
|
'emacs': 'ace/keyboard/emacs'
|
|
|
|
subscriptions:
|
|
'level:disable-controls': 'onDisableControls'
|
|
'level:enable-controls': 'onEnableControls'
|
|
'surface:frame-changed': 'onFrameChanged'
|
|
'surface:coordinate-selected': 'onCoordinateSelected'
|
|
'god:new-world-created': 'onNewWorld'
|
|
'god:user-code-problem': 'onUserCodeProblem'
|
|
'god:non-user-code-problem': 'onNonUserCodeProblem'
|
|
'tome:manual-cast': 'onManualCast'
|
|
'tome:reload-code': 'onCodeReload'
|
|
'tome:spell-changed': 'onSpellChanged'
|
|
'level:session-will-save': 'onSessionWillSave'
|
|
'modal:closed': 'focus'
|
|
'tome:focus-editor': 'focus'
|
|
'tome:spell-statement-index-updated': 'onStatementIndexUpdated'
|
|
'tome:change-language': 'onChangeLanguage'
|
|
'tome:change-config': 'onChangeEditorConfig'
|
|
'tome:update-snippets': 'addAutocompleteSnippets'
|
|
'tome:insert-snippet': 'onInsertSnippet'
|
|
'tome:spell-beautify': 'onSpellBeautify'
|
|
'tome:maximize-toggled': 'onMaximizeToggled'
|
|
'tome:problems-updated': 'onProblemsUpdated'
|
|
'script:state-changed': 'onScriptStateChange'
|
|
'playback:ended-changed': 'onPlaybackEndedChanged'
|
|
'level:contact-button-pressed': 'onContactButtonPressed'
|
|
'level:show-victory': 'onShowVictory'
|
|
'web-dev:error': 'onWebDevError'
|
|
|
|
events:
|
|
'mouseout': 'onMouseOut'
|
|
|
|
constructor: (options) ->
|
|
super options
|
|
@supermodel = options.supermodel
|
|
@worker = options.worker
|
|
@session = options.session
|
|
@spell = options.spell
|
|
@problems = []
|
|
@savedProblems = {} # Cache saved user code problems to prevent duplicates
|
|
@writable = false unless me.team in @spell.permissions.readwrite # TODO: make this do anything
|
|
@highlightCurrentLine = _.throttle @highlightCurrentLine, 100
|
|
$(window).on 'resize', @onWindowResize
|
|
@observing = @session.get('creator') isnt me.id
|
|
|
|
afterRender: ->
|
|
super()
|
|
@createACE()
|
|
@createACEShortcuts()
|
|
@hookACECustomBehavior()
|
|
@fillACE()
|
|
@createOnCodeChangeHandlers()
|
|
@lockDefaultCode()
|
|
_.defer @onAllLoaded # Needs to happen after the code generating this view is complete
|
|
|
|
# This ACE is used for the code editor, and is only instantiated once per level.
|
|
createACE: ->
|
|
# Test themes and settings here: http://ace.ajax.org/build/kitchen-sink.html
|
|
aceConfig = me.get('aceConfig') ? {}
|
|
@destroyAceEditor(@ace)
|
|
@ace = ace.edit @$el.find('.ace')[0]
|
|
@aceSession = @ace.getSession()
|
|
# Override setAnnotations so the Ace html worker doesn't clobber our annotations
|
|
@reallySetAnnotations = @aceSession.setAnnotations.bind(@aceSession)
|
|
@aceSession.setAnnotations = (annotations) =>
|
|
previousAnnotations = @aceSession.getAnnotations()
|
|
newAnnotations = _.filter previousAnnotations, (annotation) -> annotation.createdBy? # Keep the ones we generated
|
|
.concat _.reject annotations, (annotation) -> # Ignore this particular info-annotation the html worker generates
|
|
annotation.text is 'Start tag seen without seeing a doctype first. Expected e.g. <!DOCTYPE html>.'
|
|
@reallySetAnnotations newAnnotations
|
|
@aceDoc = @aceSession.getDocument()
|
|
@aceSession.setUseWorker @spell.language in @languagesThatUseWorkers
|
|
@aceSession.setMode utils.aceEditModes[@spell.language]
|
|
@aceSession.setWrapLimitRange null
|
|
@aceSession.setUseWrapMode true
|
|
@aceSession.setNewLineMode 'unix'
|
|
@aceSession.setUseSoftTabs true
|
|
@ace.setTheme 'ace/theme/textmate'
|
|
@ace.setDisplayIndentGuides false
|
|
@ace.setShowPrintMargin false
|
|
@ace.setShowInvisibles aceConfig.invisibles
|
|
@ace.setBehavioursEnabled aceConfig.behaviors
|
|
@ace.setAnimatedScroll true
|
|
@ace.setShowFoldWidgets false
|
|
@ace.setKeyboardHandler @keyBindings[aceConfig.keyBindings ? 'default']
|
|
@ace.$blockScrolling = Infinity
|
|
@toggleControls null, @writable
|
|
@aceSession.selection.on 'changeCursor', @onCursorActivity
|
|
$(@ace.container).find('.ace_gutter').on 'click mouseenter', '.ace_error, .ace_warning, .ace_info', @onAnnotationClick
|
|
$(@ace.container).find('.ace_gutter').on 'click', @onGutterClick
|
|
@initAutocomplete aceConfig.liveCompletion ? true
|
|
|
|
return if @session.get('creator') isnt me.id or @session.fake
|
|
# Create a Spade to 'dig' into Ace.
|
|
@spade = new Spade()
|
|
@spade.track(@ace)
|
|
# If a user is taking longer than 10 minutes, let's log it.
|
|
saveSpadeDelay = 10 * 60 * 1000
|
|
@saveSpadeTimeout = setTimeout @saveSpade, saveSpadeDelay
|
|
|
|
createACEShortcuts: ->
|
|
@aceCommands = aceCommands = []
|
|
ace = @ace
|
|
addCommand = (c) ->
|
|
ace.commands.addCommand c
|
|
aceCommands.push c.name
|
|
addCommand
|
|
name: 'run-code'
|
|
bindKey: {win: 'Shift-Enter|Ctrl-Enter', mac: 'Shift-Enter|Command-Enter|Ctrl-Enter'}
|
|
exec: => Backbone.Mediator.publish 'tome:manual-cast', {realTime: @options.level.isType('game-dev')}
|
|
unless @observing
|
|
addCommand
|
|
name: 'run-code-real-time'
|
|
bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'}
|
|
exec: =>
|
|
doneButton = @$('.done-button:visible')
|
|
if doneButton.length
|
|
doneButton.trigger 'click'
|
|
else if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0
|
|
Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit
|
|
else
|
|
Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}
|
|
addCommand
|
|
name: 'no-op'
|
|
bindKey: {win: 'Ctrl-S', mac: 'Command-S|Ctrl-S'}
|
|
exec: -> # just prevent page save call
|
|
addCommand
|
|
name: 'toggle-playing'
|
|
bindKey: {win: 'Ctrl-P', mac: 'Command-P|Ctrl-P'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'level:toggle-playing', {}
|
|
addCommand
|
|
name: 'end-current-script'
|
|
bindKey: {win: 'Shift-Space', mac: 'Shift-Space'}
|
|
readOnly: true
|
|
exec: =>
|
|
if @scriptRunning
|
|
Backbone.Mediator.publish 'level:shift-space-pressed', {}
|
|
else
|
|
@ace.insert ' '
|
|
addCommand
|
|
name: 'end-all-scripts'
|
|
bindKey: {win: 'Escape', mac: 'Escape'}
|
|
readOnly: true
|
|
exec: ->
|
|
Backbone.Mediator.publish 'level:escape-pressed', {}
|
|
addCommand
|
|
name: 'toggle-grid'
|
|
bindKey: {win: 'Ctrl-G', mac: 'Command-G|Ctrl-G'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'level:toggle-grid', {}
|
|
addCommand
|
|
name: 'toggle-debug'
|
|
bindKey: {win: 'Ctrl-\\', mac: 'Command-\\|Ctrl-\\'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'level:toggle-debug', {}
|
|
addCommand
|
|
name: 'toggle-pathfinding'
|
|
bindKey: {win: 'Ctrl-O', mac: 'Command-O|Ctrl-O'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'level:toggle-pathfinding', {}
|
|
addCommand
|
|
name: 'level-scrub-forward'
|
|
bindKey: {win: 'Ctrl-]', mac: 'Command-]|Ctrl-]'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'level:scrub-forward', {}
|
|
addCommand
|
|
name: 'level-scrub-back'
|
|
bindKey: {win: 'Ctrl-[', mac: 'Command-[|Ctrl-]'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'level:scrub-back', {}
|
|
addCommand
|
|
name: 'spell-step-forward'
|
|
bindKey: {win: 'Ctrl-Alt-]', mac: 'Command-Alt-]|Ctrl-Alt-]'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'tome:spell-step-forward', {}
|
|
addCommand
|
|
name: 'spell-step-backward'
|
|
bindKey: {win: 'Ctrl-Alt-[', mac: 'Command-Alt-[|Ctrl-Alt-]'}
|
|
readOnly: true
|
|
exec: -> Backbone.Mediator.publish 'tome:spell-step-backward', {}
|
|
addCommand
|
|
name: 'spell-beautify'
|
|
bindKey: {win: 'Ctrl-Shift-B', mac: 'Command-Shift-B|Ctrl-Shift-B'}
|
|
exec: -> Backbone.Mediator.publish 'tome:spell-beautify', {}
|
|
addCommand
|
|
name: 'prevent-line-jump'
|
|
bindKey: {win: 'Ctrl-L', mac: 'Command-L'}
|
|
passEvent: true
|
|
exec: -> # just prevent default ACE go-to-line alert
|
|
addCommand
|
|
name: 'open-fullscreen-editor'
|
|
bindKey: {win: 'Ctrl-Shift-M', mac: 'Command-Shift-M|Ctrl-Shift-M'}
|
|
exec: -> Backbone.Mediator.publish 'tome:toggle-maximize', {}
|
|
addCommand
|
|
# TODO: Restrict to beginner campaign levels like we do backspaceThrottle
|
|
name: 'enter-skip-delimiters'
|
|
bindKey: 'Enter|Return'
|
|
exec: =>
|
|
if @aceSession.selection.isEmpty()
|
|
cursor = @ace.getCursorPosition()
|
|
line = @aceDoc.getLine(cursor.row)
|
|
if delimMatch = line.substring(cursor.column).match /^(["|']?\)+;?)/ # Yay for editors misreading regexes: "
|
|
newRange = @ace.getSelectionRange()
|
|
newRange.setStart newRange.start.row, newRange.start.column + delimMatch[1].length
|
|
newRange.setEnd newRange.end.row, newRange.end.column + delimMatch[1].length
|
|
@aceSession.selection.setSelectionRange newRange
|
|
@ace.execCommand 'insertstring', '\n'
|
|
addCommand
|
|
name: 'disable-spaces'
|
|
bindKey: 'Space'
|
|
exec: =>
|
|
disableSpaces = @options.level.get('disableSpaces') or false
|
|
aceConfig = me.get('aceConfig') ? {}
|
|
disableSpaces = false if aceConfig.keyBindings and aceConfig.keyBindings isnt 'default' # Not in vim/emacs mode
|
|
disableSpaces = false if @spell.language in ['lua', 'java', 'coffeescript', 'html'] # Don't disable for more advanced/experimental languages
|
|
if not disableSpaces or (_.isNumber(disableSpaces) and disableSpaces < me.level())
|
|
return @ace.execCommand 'insertstring', ' '
|
|
line = @aceDoc.getLine @ace.getCursorPosition().row
|
|
return @ace.execCommand 'insertstring', ' ' if @singleLineCommentRegex().test line
|
|
|
|
if @options.level.get 'backspaceThrottle'
|
|
addCommand
|
|
name: 'throttle-backspaces'
|
|
bindKey: 'Backspace'
|
|
exec: =>
|
|
# Throttle the backspace speed
|
|
# Slow to 500ms when whitespace at beginning of line is first encountered
|
|
# Slow to 100ms for remaining whitespace at beginning of line
|
|
# Rough testing showed backspaces happen at 150ms when tapping.
|
|
# Backspace speed varies by system when holding, 30ms on fastest Macbook setting.
|
|
nowDate = Date.now()
|
|
if @aceSession.selection.isEmpty()
|
|
cursor = @ace.getCursorPosition()
|
|
line = @aceDoc.getLine(cursor.row)
|
|
if /^\s*$/.test line.substring(0, cursor.column)
|
|
@backspaceThrottleMs ?= 500
|
|
# console.log "SpellView @backspaceThrottleMs=#{@backspaceThrottleMs}"
|
|
# console.log 'SpellView lastBackspace diff', nowDate - @lastBackspace if @lastBackspace?
|
|
if not @lastBackspace? or nowDate - @lastBackspace > @backspaceThrottleMs
|
|
@backspaceThrottleMs = 100
|
|
@lastBackspace = nowDate
|
|
@ace.remove "left"
|
|
return
|
|
@backspaceThrottleMs = null
|
|
@lastBackspace = nowDate
|
|
@ace.remove "left"
|
|
|
|
|
|
hookACECustomBehavior: ->
|
|
aceConfig = me.get('aceConfig') ? {}
|
|
@ace.commands.on 'exec', (e) =>
|
|
# When pressing enter with an active selection, just make a new line under it.
|
|
if e.command.name is 'enter-skip-delimiters'
|
|
selection = @ace.selection.getRange()
|
|
unless selection.start.column is selection.end.column and selection.start.row is selection.end.row
|
|
e.editor.execCommand 'gotolineend'
|
|
return true
|
|
|
|
# Add visual indent guides
|
|
language = @spell.language
|
|
ensureLineStartsBlock = (line) ->
|
|
return false unless language is "python"
|
|
match = /^\s*([^#]+)/.exec(line)
|
|
return false if not match?
|
|
return /:\s*$/.test(match[1])
|
|
|
|
@aceSession.addDynamicMarker
|
|
update: (html, markerLayer, session, config) =>
|
|
Range = ace.require('ace/range').Range
|
|
|
|
foldWidgets = @aceSession.foldWidgets
|
|
return if not foldWidgets?
|
|
|
|
lines = @aceDoc.getAllLines()
|
|
startOfRow = (r) ->
|
|
str = lines[r]
|
|
ar = str.match(/^\s*/)
|
|
ar.pop().length
|
|
|
|
colors = [{border: '74,144,226', fill: '108,162,226'}, {border: '132,180,235', fill: '230,237,245'}]
|
|
|
|
for row in [0..@aceSession.getLength()]
|
|
foldWidgets[row] = @aceSession.getFoldWidget(row) unless foldWidgets[row]?
|
|
continue unless foldWidgets? and foldWidgets[row] is "start"
|
|
try
|
|
docRange = @aceSession.getFoldWidgetRange(row)
|
|
catch error
|
|
console.warn "Couldn't find fold widget docRange for row #{row}:", error
|
|
if not docRange?
|
|
guess = startOfRow(row)
|
|
docRange = new Range(row,guess,row,guess+4)
|
|
|
|
continue unless ensureLineStartsBlock(lines[row])
|
|
|
|
if /^\s+$/.test lines[docRange.end.row+1]
|
|
docRange.end.row += 1
|
|
|
|
xstart = startOfRow(row)
|
|
if language is 'python'
|
|
requiredIndent = new RegExp '^' + new Array(Math.floor(xstart / 4 + 1)).join('( |\t)') + '( |\t)+(\\S|\\s*$)'
|
|
for crow in [docRange.start.row+1..docRange.end.row]
|
|
unless requiredIndent.test lines[crow]
|
|
docRange.end.row = crow - 1
|
|
break
|
|
|
|
rstart = @aceSession.documentToScreenPosition docRange.start.row, docRange.start.column
|
|
rend = @aceSession.documentToScreenPosition docRange.end.row, docRange.end.column
|
|
range = new Range rstart.row, rstart.column, rend.row, rend.column
|
|
level = Math.floor(xstart / 4)
|
|
color = colors[level % colors.length]
|
|
bw = 3
|
|
to = markerLayer.$getTop(range.start.row, config)
|
|
t = markerLayer.$getTop(range.start.row + 1, config)
|
|
h = config.lineHeight * (range.end.row - range.start.row)
|
|
l = markerLayer.$padding + xstart * config.characterWidth
|
|
# w = (data.i - data.b) * config.characterWidth
|
|
w = 4 * config.characterWidth
|
|
fw = config.characterWidth * ( @aceSession.getScreenLastRowColumn(range.start.row) - xstart )
|
|
|
|
html.push """
|
|
<div style=
|
|
"position: absolute; top: #{to}px; left: #{l}px; width: #{fw+bw}px; height: #{config.lineHeight}px;
|
|
border: #{bw}px solid rgba(#{color.border},1); border-left: none;"
|
|
></div>
|
|
<div style=
|
|
"position: absolute; top: #{t}px; left: #{l}px; width: #{w}px; height: #{h}px; background-color: rgba(#{color.fill},0.5);
|
|
border-right: #{bw}px solid rgba(#{color.border},1); border-bottom: #{bw}px solid rgba(#{color.border},1);"
|
|
></div>
|
|
"""
|
|
|
|
fillACE: ->
|
|
@ace.setValue @spell.source
|
|
@aceSession.setUndoManager(new UndoManager())
|
|
@ace.clearSelection()
|
|
|
|
lockDefaultCode: (force=false) ->
|
|
# TODO: Lock default indent for an empty line?
|
|
lockDefaultCode = @options.level.get('lockDefaultCode') or false
|
|
if not lockDefaultCode or (_.isNumber(lockDefaultCode) and lockDefaultCode < me.level())
|
|
return
|
|
return unless @spell.source is @spell.originalSource or force
|
|
return if @isIE() # Temporary workaround for #2512
|
|
aceConfig = me.get('aceConfig') ? {}
|
|
return if aceConfig.keyBindings and aceConfig.keyBindings isnt 'default' # Don't lock in vim/emacs mode
|
|
|
|
console.info 'Locking down default code.'
|
|
|
|
intersects = =>
|
|
return true for range in @readOnlyRanges when @ace.getSelectionRange().intersects(range)
|
|
false
|
|
|
|
intersectsLeft = =>
|
|
leftRange = @ace.getSelectionRange().clone()
|
|
if leftRange.start.column > 0
|
|
leftRange.setStart leftRange.start.row, leftRange.start.column - 1
|
|
else if leftRange.start.row > 0
|
|
leftRange.setStart leftRange.start.row - 1, 0
|
|
return true for range in @readOnlyRanges when leftRange.intersects(range)
|
|
false
|
|
|
|
intersectsRight = =>
|
|
rightRange = @ace.getSelectionRange().clone()
|
|
if rightRange.end.column < @aceDoc.getLine(rightRange.end.row).length
|
|
rightRange.setEnd rightRange.end.row, rightRange.end.column + 1
|
|
else if rightRange.start.row < @aceDoc.getLength() - 1
|
|
rightRange.setEnd rightRange.end.row + 1, 0
|
|
return true for range in @readOnlyRanges when rightRange.intersects(range)
|
|
false
|
|
|
|
pulseLockedCode = ->
|
|
$('.locked-code').finish().addClass('pulsating').effect('shake', times: 1, distance: 2, direction: 'down').removeClass('pulsating')
|
|
|
|
preventReadonly = (next) ->
|
|
if intersects()
|
|
pulseLockedCode()
|
|
return true
|
|
next?()
|
|
|
|
interceptCommand = (obj, method, wrapper) ->
|
|
orig = obj[method]
|
|
obj[method] = ->
|
|
args = Array.prototype.slice.call arguments
|
|
wrapper => orig.apply obj, args
|
|
obj[method]
|
|
|
|
finishRange = (row, startRow, startColumn) =>
|
|
range = new Range startRow, startColumn, row, @aceSession.getLine(row).length - 1
|
|
range.start = @aceDoc.createAnchor range.start
|
|
range.end = @aceDoc.createAnchor range.end
|
|
range.end.$insertRight = true
|
|
@readOnlyRanges.push range
|
|
|
|
# Remove previous locked code highlighting
|
|
if @lockedCodeMarkerIDs?
|
|
@aceSession.removeMarker marker for marker in @lockedCodeMarkerIDs
|
|
@lockedCodeMarkerIDs = []
|
|
|
|
# Create locked default code text ranges
|
|
@readOnlyRanges = []
|
|
if @spell.language in ['python', 'coffeescript']
|
|
# Lock contiguous section of default code
|
|
# Only works for languages without closing delimeters on blocks currently
|
|
lines = @aceDoc.getAllLines()
|
|
for line, row in lines when not /^\s*$/.test(line)
|
|
lastRow = row
|
|
if lastRow?
|
|
@readOnlyRanges.push new Range 0, 0, lastRow, lines[lastRow].length - 1
|
|
|
|
# TODO: Highlighting does not work for multiple ranges
|
|
# TODO: Everything looks correct except the actual result.
|
|
# TODO: https://github.com/codecombat/codecombat/issues/1852
|
|
# else
|
|
# # Create a read-only range for each chunk of text not separated by an empty line
|
|
# startRow = startColumn = null
|
|
# for row in [0...@aceSession.getLength()]
|
|
# unless /^\s*$/.test @aceSession.getLine(row)
|
|
# unless startRow? and startColumn?
|
|
# startRow = row
|
|
# startColumn = 0
|
|
# else
|
|
# if startRow? and startColumn?
|
|
# finishRange row - 1, startRow, startColumn
|
|
# startRow = startColumn = null
|
|
# if startRow? and startColumn?
|
|
# finishRange @aceSession.getLength() - 1, startRow, startColumn
|
|
|
|
# Highlight locked ranges
|
|
for range in @readOnlyRanges
|
|
@lockedCodeMarkerIDs.push @aceSession.addMarker range, 'locked-code', 'fullLine'
|
|
|
|
# Override write operations that intersect with default code
|
|
interceptCommand @ace, 'onPaste', preventReadonly
|
|
interceptCommand @ace, 'onCut', preventReadonly
|
|
# TODO: can we use interceptCommand for this too? 'exec' and 'onExec' did not work.
|
|
@ace.commands.on 'exec', (e) =>
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
if (e.command.name is 'insertstring' and intersects()) or
|
|
(e.command.name in ['Backspace', 'throttle-backspaces'] and intersectsLeft()) or
|
|
(e.command.name is 'del' and intersectsRight())
|
|
@autocomplete?.off?()
|
|
pulseLockedCode()
|
|
return false
|
|
else if e.command.name in ['enter-skip-delimiters', 'Enter', 'Return']
|
|
if intersects()
|
|
e.editor.navigateDown 1
|
|
e.editor.navigateLineStart()
|
|
return false
|
|
else if e.command.name in ['Enter', 'Return'] and not e.editor?.completer?.popup?.isOpen
|
|
@autocomplete?.on?()
|
|
return e.editor.execCommand 'enter-skip-delimiters'
|
|
@autocomplete?.on?()
|
|
e.command.exec e.editor, e.args or {}
|
|
|
|
initAutocomplete: (@autocompleteOn) ->
|
|
# TODO: Turn on more autocompletion based on level sophistication
|
|
# TODO: E.g. using the language default snippets yields a bunch of crazy non-beginner suggestions
|
|
# TODO: Options logic shouldn't exist both here and in updateAutocomplete()
|
|
return if @spell.language is 'html'
|
|
popupFontSizePx = @options.level.get('autocompleteFontSizePx') ? 16
|
|
@autocomplete = new Autocomplete @ace,
|
|
basic: false
|
|
liveCompletion: false
|
|
snippetsLangDefaults: false
|
|
completers:
|
|
keywords: false
|
|
snippets: @autocompleteOn
|
|
autoLineEndings:
|
|
javascript: ';'
|
|
popupFontSizePx: popupFontSizePx
|
|
popupLineHeightPx: 1.5 * popupFontSizePx
|
|
popupWidthPx: 380
|
|
|
|
updateAutocomplete: (@autocompleteOn) ->
|
|
@autocomplete?.set 'snippets', @autocompleteOn
|
|
|
|
addAutocompleteSnippets: (e) ->
|
|
# Snippet entry format:
|
|
# content: code inserted into document
|
|
# meta: displayed right-justfied in popup
|
|
# name: displayed left-justified in popup, and what's being matched
|
|
# tabTrigger: fallback for name field
|
|
return unless @autocomplete and @autocompleteOn
|
|
@autocomplete.addCodeCombatSnippets @options.level, @, e
|
|
|
|
translateFindNearest: ->
|
|
# If they have advanced glasses but are playing a level which assumes earlier glasses, we'll adjust the sample code to use the more advanced APIs instead.
|
|
oldSource = @getSource()
|
|
newSource = oldSource.replace /(self:|self.|this.|@)findNearestEnemy\(\)/g, "$1findNearest($1findEnemies())"
|
|
newSource = newSource.replace /(self:|self.|this.|@)findNearestItem\(\)/g, "$1findNearest($1findItems())"
|
|
return if oldSource is newSource
|
|
@spell.originalSource = newSource
|
|
@updateACEText newSource
|
|
_.delay (=> @recompile?()), 1000
|
|
|
|
createFirepad: ->
|
|
# Currently not called; could be brought back for future multiplayer modes.
|
|
# Load from firebase or the original source if there's nothing there.
|
|
return if @firepadLoading
|
|
@eventsSuppressed = true
|
|
@loaded = false
|
|
@previousSource = @ace.getValue()
|
|
@ace.setValue('')
|
|
@aceSession.setUndoManager(new UndoManager())
|
|
fireURL = 'https://codecombat.firebaseio.com/' + @spell.pathComponents.join('/')
|
|
@fireRef = new Firebase fireURL
|
|
firepadOptions = userId: me.id
|
|
@firepad = Firepad.fromACE @fireRef, @ace, firepadOptions
|
|
@firepadLoading = true
|
|
@firepad.on 'ready', =>
|
|
return if @destroyed
|
|
@firepadLoading = false
|
|
firepadSource = @ace.getValue()
|
|
if firepadSource
|
|
@spell.source = firepadSource
|
|
else
|
|
@ace.setValue @previousSource
|
|
@aceSession.setUndoManager(new UndoManager())
|
|
@ace.clearSelection()
|
|
@onAllLoaded()
|
|
|
|
onAllLoaded: =>
|
|
@spell.transpile @spell.source
|
|
@spell.loaded = true
|
|
Backbone.Mediator.publish 'tome:spell-loaded', spell: @spell
|
|
@eventsSuppressed = false # Now that the initial change is in, we can start running any changed code
|
|
@createToolbarView()
|
|
@updateHTML create: true if @options.level.isType('web-dev')
|
|
|
|
createDebugView: ->
|
|
return if @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') # We'll turn this on later, maybe, but not yet.
|
|
@debugView = new SpellDebugView ace: @ace, thang: @thang, spell:@spell
|
|
@$el.append @debugView.render().$el.hide()
|
|
|
|
createTranslationView: ->
|
|
@translationView = new SpellTranslationView { @ace, @supermodel }
|
|
@$el.append @translationView.render().$el.hide()
|
|
|
|
createToolbarView: ->
|
|
@toolbarView = new SpellToolbarView ace: @ace
|
|
@$el.append @toolbarView.render().$el
|
|
|
|
onMouseOut: (e) ->
|
|
@debugView?.onMouseOut e
|
|
|
|
onContactButtonPressed: (e) ->
|
|
@saveSpade()
|
|
|
|
getSource: ->
|
|
@ace.getValue()
|
|
|
|
setThang: (thang) ->
|
|
@focus()
|
|
@lastScreenLineCount = null
|
|
@updateLines()
|
|
return if thang.id is @thang?.id
|
|
@thang = thang
|
|
@spellThang = @spell.thang
|
|
@createDebugView() unless @debugView
|
|
@debugView?.thang = @thang
|
|
@createTranslationView() unless @translationView
|
|
@toolbarView?.toggleFlow false
|
|
@updateAether false, false
|
|
# @addAutocompleteSnippets()
|
|
@highlightCurrentLine()
|
|
|
|
cast: (preload=false, realTime=false, justBegin=false) ->
|
|
Backbone.Mediator.publish 'tome:cast-spell', { @spell, @thang, preload, realTime, justBegin }
|
|
|
|
notifySpellChanged: =>
|
|
return if @destroyed
|
|
Backbone.Mediator.publish 'tome:spell-changed', spell: @spell
|
|
|
|
notifyEditingEnded: =>
|
|
return if @destroyed or @aceDoc.undergoingFirepadOperation # from my Firepad ACE adapter
|
|
Backbone.Mediator.publish 'tome:editing-ended', {}
|
|
|
|
notifyEditingBegan: =>
|
|
return if @destroyed or @aceDoc.undergoingFirepadOperation # from my Firepad ACE adapter
|
|
Backbone.Mediator.publish 'tome:editing-began', {}
|
|
|
|
updateLines: =>
|
|
# Make sure there are always blank lines for the player to type on, and that the editor resizes to the height of the lines.
|
|
return if @destroyed
|
|
lineCount = @aceDoc.getLength()
|
|
lastLine = @aceDoc.$lines[lineCount - 1]
|
|
if lastLine isnt ''
|
|
cursorPosition = @ace.getCursorPosition()
|
|
wasAtEnd = cursorPosition.row is lineCount - 1 and cursorPosition.column is lastLine.length
|
|
@aceDoc.insertNewLine row: lineCount, column: 0 #lastLine.length
|
|
@ace.navigateLeft(1) if wasAtEnd
|
|
++lineCount
|
|
# Force the popup back
|
|
@ace?.completer?.showPopup(@ace)
|
|
screenLineCount = @aceSession.getScreenLength()
|
|
if screenLineCount isnt @lastScreenLineCount
|
|
@lastScreenLineCount = screenLineCount
|
|
lineHeight = @ace.renderer.lineHeight or 20
|
|
tomeHeight = $('#tome-view').innerHeight()
|
|
spellPaletteView = $('#spell-palette-view')
|
|
spellTopBarHeight = $('#spell-top-bar-view').outerHeight()
|
|
spellToolbarHeight = $('.spell-toolbar-view').outerHeight()
|
|
@spellPaletteHeight ?= spellPaletteView.outerHeight() # Remember this until resize, since we change it afterward
|
|
spellPaletteAllowedHeight = Math.min @spellPaletteHeight, tomeHeight / 3
|
|
maxHeight = tomeHeight - spellTopBarHeight - spellToolbarHeight - spellPaletteAllowedHeight
|
|
linesAtMaxHeight = Math.floor(maxHeight / lineHeight)
|
|
lines = Math.max 8, Math.min(screenLineCount + 2, linesAtMaxHeight)
|
|
# 2 lines buffer is nice
|
|
@ace.setOptions minLines: lines, maxLines: lines
|
|
# Move spell palette up, slightly overlapping us.
|
|
newTop = 185 + lineHeight * lines
|
|
spellPaletteView.css('top', newTop)
|
|
# Expand it to bottom of tome if too short.
|
|
newHeight = Math.max @spellPaletteHeight, tomeHeight - newTop + 10
|
|
spellPaletteView.css('height', newHeight) if @spellPaletteHeight isnt newHeight
|
|
|
|
hideProblemAlert: ->
|
|
return if @destroyed
|
|
Backbone.Mediator.publish 'tome:hide-problem-alert', {}
|
|
|
|
saveSpade: =>
|
|
return if @destroyed
|
|
spadeEvents = @spade.compile()
|
|
# Uncomment the below line for a debug panel to display inside the level
|
|
#@spade.debugPlay(spadeEvents)
|
|
condensedEvents = @spade.condense(spadeEvents)
|
|
|
|
return unless condensedEvents.length
|
|
compressedEvents = LZString.compressToUTF16(JSON.stringify(condensedEvents))
|
|
|
|
codeLog = new CodeLog({
|
|
sessionID: @options.session.id
|
|
level:
|
|
original: @options.level.get 'original'
|
|
majorVersion: (@options.level.get 'version').major
|
|
levelSlug: @options.level.get 'slug'
|
|
userID: @options.session.get 'creator'
|
|
log: compressedEvents
|
|
})
|
|
|
|
codeLog.save()
|
|
|
|
onShowVictory: (e) ->
|
|
if @saveSpadeTimeout?
|
|
window.clearTimeout @saveSpadeTimeout
|
|
@saveSpadeTimeout = null
|
|
|
|
onManualCast: (e) ->
|
|
cast = @$el.parent().length
|
|
@recompile cast, e.realTime
|
|
@focus() if cast
|
|
if @options.level.isType('web-dev')
|
|
@sourceAtLastCast = @getSource()
|
|
@ace.setStyle 'spell-cast'
|
|
@updateHTML create: true
|
|
|
|
onCodeReload: (e) ->
|
|
return unless e.spell is @spell or not e.spell
|
|
@reloadCode true
|
|
@ace.clearSelection()
|
|
_.delay (=> @ace?.clearSelection()), 500 # Make double sure this gets done (saw some timing issues?)
|
|
|
|
reloadCode: (cast=true) ->
|
|
@updateACEText @spell.originalSource
|
|
@lockDefaultCode true
|
|
@recompile cast
|
|
Backbone.Mediator.publish 'tome:spell-loaded', spell: @spell
|
|
@updateLines()
|
|
|
|
recompile: (cast=true, realTime=false) ->
|
|
hasChanged = @spell.source isnt @getSource()
|
|
if hasChanged
|
|
@spell.transpile @getSource()
|
|
@updateAether true, false
|
|
if cast #and (hasChanged or realTime) # just always cast now
|
|
@cast(false, realTime)
|
|
if hasChanged
|
|
@notifySpellChanged()
|
|
|
|
updateACEText: (source) ->
|
|
@eventsSuppressed = true
|
|
if @firepad
|
|
@firepad.setText source
|
|
else
|
|
@ace.setValue source
|
|
@aceSession.setUndoManager(new UndoManager())
|
|
@eventsSuppressed = false
|
|
try
|
|
@ace.resize true # hack: @ace may not have updated its text properly, so we force it to refresh
|
|
catch error
|
|
console.warn 'Error resizing ACE after an update:', error
|
|
|
|
createOnCodeChangeHandlers: ->
|
|
@aceDoc.removeListener 'change', @onCodeChangeMetaHandler if @onCodeChangeMetaHandler
|
|
onSignificantChange = []
|
|
onAnyChange = [
|
|
_.debounce @updateAether, if @options.level.isType('game-dev') then 10 else 500
|
|
_.debounce @notifyEditingEnded, 1000
|
|
_.throttle @notifyEditingBegan, 250
|
|
_.throttle @notifySpellChanged, 300
|
|
_.throttle @updateLines, 500
|
|
_.throttle @hideProblemAlert, 500
|
|
]
|
|
onSignificantChange.push _.debounce @checkRequiredCode, 750 if @options.level.get 'requiredCode'
|
|
onSignificantChange.push _.debounce @checkSuspectCode, 750 if @options.level.get 'suspectCode'
|
|
onAnyChange.push _.throttle @updateHTML, 10 if @options.level.isType('web-dev')
|
|
|
|
@onCodeChangeMetaHandler = =>
|
|
return if @eventsSuppressed
|
|
#@playSound 'code-change', volume: 0.5 # Currently not using this sound.
|
|
if @spellThang
|
|
@spell.hasChangedSignificantly @getSource(), @spellThang.aether.raw, (hasChanged) =>
|
|
if not @spellThang or hasChanged
|
|
callback() for callback in onSignificantChange # Do these first
|
|
callback() for callback in onAnyChange # Then these
|
|
@aceDoc.on 'change', @onCodeChangeMetaHandler
|
|
|
|
onCursorActivity: => # Used to refresh autocast delay; doesn't do anything at the moment.
|
|
|
|
updateHTML: (options={}) =>
|
|
# TODO: Merge with onSpellChanged
|
|
# NOTE: Consider what goes in onManualCast only
|
|
if @spell.hasChanged(@spell.getSource(), @sourceAtLastCast)
|
|
@ace.unsetStyle 'spell-cast' # NOTE: Doesn't do anything for web-dev as of this writing, including for consistency
|
|
@clearWebDevErrors()
|
|
Backbone.Mediator.publish 'tome:html-updated', html: @spell.constructHTML(@getSource()), create: Boolean(options.create)
|
|
|
|
# Design for a simpler system?
|
|
# * Keep Aether linting, debounced, on any significant change
|
|
# - All problems just vanish when you make any change to the code
|
|
# * You wouldn't accept any Aether updates/runtime information/errors unless its code was current when you got it
|
|
# * Store the last run Aether in each spellThang and use it whenever its code actually is current.
|
|
# Use dynamic markers for problem ranges and keep annotations/alerts in when insignificant
|
|
# changes happen, but always treat any change in the (trimmed) number of lines as a significant change.
|
|
# - All problems have a master representation as a Problem, and we can easily generate all Problems from
|
|
# any Aether instance. Then when we switch contexts in any way, we clear, recreate, and reapply the Problems.
|
|
# * Problem alerts have their own templated ProblemAlertViews.
|
|
# * We'll only show the first problem alert, and it will always be at the bottom.
|
|
# Annotations and problem ranges can show all, I guess.
|
|
# * The editor will reserve space for one annotation as a codeless area.
|
|
# - Problem alerts and ranges will only show on fully cast worlds. Annotations will show continually.
|
|
|
|
updateAether: (force=false, fromCodeChange=true) =>
|
|
# Depending on whether we have any code changes, significant code changes, or have switched
|
|
# to a new spellThang, we may want to refresh our Aether display.
|
|
return unless aether = @spellThang?.aether
|
|
source = @getSource()
|
|
@spell.hasChangedSignificantly source, aether.raw, (hasChanged) =>
|
|
codeHasChangedSignificantly = force or hasChanged
|
|
needsUpdate = codeHasChangedSignificantly or @spellThang isnt @lastUpdatedAetherSpellThang
|
|
return if not needsUpdate and aether is @displayedAether
|
|
castAether = @spellThang.castAether
|
|
codeIsAsCast = castAether and source is castAether.raw
|
|
aether = castAether if codeIsAsCast
|
|
return if not needsUpdate and aether is @displayedAether
|
|
|
|
# Now that that's figured out, perform the update.
|
|
# The web worker Aether won't track state, so don't have to worry about updating it
|
|
finishUpdatingAether = (aether) =>
|
|
@clearAetherDisplay() # In case problems were added since last clearing
|
|
@displayAether aether, codeIsAsCast
|
|
@lastUpdatedAetherSpellThang = @spellThang
|
|
@guessWhetherFinished aether if fromCodeChange
|
|
|
|
@clearAetherDisplay()
|
|
if codeHasChangedSignificantly and not codeIsAsCast
|
|
if @worker
|
|
workerMessage =
|
|
function: 'transpile'
|
|
spellKey: @spell.spellKey
|
|
source: source
|
|
|
|
@worker.addEventListener 'message', (e) =>
|
|
workerData = JSON.parse e.data
|
|
if workerData.function is 'transpile' and workerData.spellKey is @spell.spellKey
|
|
@worker.removeEventListener 'message', arguments.callee, false
|
|
aether.problems = workerData.problems
|
|
aether.raw = source
|
|
finishUpdatingAether(aether)
|
|
@worker.postMessage JSON.stringify(workerMessage)
|
|
else
|
|
aether.transpile source
|
|
finishUpdatingAether(aether)
|
|
else
|
|
finishUpdatingAether(aether)
|
|
|
|
# Each problem-generating piece (aether, web-dev, ace html worker) clears its own problems/annotations
|
|
clearAetherDisplay: ->
|
|
@clearProblemsCreatedBy 'aether'
|
|
@highlightCurrentLine {} # This'll remove all highlights
|
|
|
|
clearWebDevErrors: ->
|
|
@clearProblemsCreatedBy 'web-dev-iframe'
|
|
|
|
clearProblemsCreatedBy: (createdBy) ->
|
|
nonAetherAnnotations = _.reject @aceSession.getAnnotations(), (annotation) -> annotation.createdBy is createdBy
|
|
@reallySetAnnotations nonAetherAnnotations
|
|
|
|
problemsToClear = _.filter @problems, (p) -> p.createdBy is createdBy
|
|
problemsToClear.forEach (problem) -> problem.destroy()
|
|
@problems = _.difference @problems, problemsToClear
|
|
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: false
|
|
|
|
convertAetherProblems: (aether, aetherProblems, isCast) ->
|
|
# TODO: Functional-ify
|
|
_.unique(aetherProblems, (p) -> p.userInfo?.key).map (aetherProblem) =>
|
|
new Problem { aether, aetherProblem, @ace, isCast, levelID: @options.levelID }
|
|
|
|
displayAether: (aether, isCast=false) ->
|
|
@displayedAether = aether
|
|
isCast = isCast or not _.isEmpty(aether.metrics) or _.some aether.getAllProblems(), {type: 'runtime'}
|
|
annotations = @aceSession.getAnnotations()
|
|
|
|
newProblems = @convertAetherProblems(aether, aether.getAllProblems(), isCast)
|
|
annotations.push problem.annotation for problem in newProblems when problem.annotation
|
|
if isCast
|
|
@displayProblemBanner(newProblems[0]) if newProblems[0]
|
|
@saveUserCodeProblem(aether, problem.aetherProblem) for problem in newProblems
|
|
@problems = @problems.concat(newProblems)
|
|
|
|
@aceSession.setAnnotations annotations
|
|
@highlightCurrentLine aether.flow unless _.isEmpty aether.flow
|
|
#console.log ' and we could do the metrics', aether.metrics unless _.isEmpty aether.metrics
|
|
#console.log ' and we could do the style', aether.style unless _.isEmpty aether.style
|
|
#console.log ' and we could do the visualization', aether.visualization unless _.isEmpty aether.visualization
|
|
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: isCast
|
|
@ace.resize()
|
|
|
|
# Tell ProblemAlertView to display this problem (only)
|
|
displayProblemBanner: (problem) ->
|
|
lineOffsetPx = 0
|
|
if problem.row?
|
|
for i in [0...problem.row]
|
|
lineOffsetPx += @aceSession.getRowLength(i) * @ace.renderer.lineHeight
|
|
lineOffsetPx -= @ace.session.getScrollTop()
|
|
Backbone.Mediator.publish 'tome:show-problem-alert', problem: problem, lineOffsetPx: Math.max lineOffsetPx, 0
|
|
|
|
# Gets the number of lines before the start of <script> content in the usercode
|
|
# Because Errors report their line number relative to the <script> tag
|
|
linesBeforeScript: (html) ->
|
|
# TODO: refactor, make it work with multiple scripts. What to do when error is in level-creator's code?
|
|
_.size(html.split('<script>')[0].match(/\n/g))
|
|
|
|
addAnnotation: (annotation) ->
|
|
annotations = @aceSession.getAnnotations()
|
|
annotations.push annotation
|
|
@reallySetAnnotations annotations
|
|
|
|
# Handle errors from the web-dev iframe asynchronously
|
|
onWebDevError: (error) ->
|
|
# TODO: Refactor this and the Aether problem flow to share as much as possible.
|
|
# TODO: Handle when the error is in our code, not theirs
|
|
# Compensate for line number being relative to <script> tag
|
|
offsetError = _.merge {}, error, { line: error.line + @linesBeforeScript(@getSource()) }
|
|
userCodeHasChangedSinceLastCast = @spell.hasChanged(@spell.getSource(), @sourceAtLastCast)
|
|
problem = new Problem({ error: offsetError, @ace, levelID: @options.levelID, userCodeHasChangedSinceLastCast })
|
|
# Ignore the Problem if we already know about it
|
|
if _.any(@problems, (preexistingProblem) -> problem.isEqual(preexistingProblem))
|
|
problem.destroy()
|
|
else # Ok, the problem is worth keeping
|
|
@problems.push problem
|
|
@displayProblemBanner(problem)
|
|
|
|
# @saveUserCodeProblem(aether, aetherProblem) # TODO: Enable saving of web-dev user code problems
|
|
@addAnnotation(problem.annotation) if problem.annotation
|
|
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: false
|
|
|
|
onProblemsUpdated: ({ spell, problems, isCast }) ->
|
|
# This just handles some ace styles for now; other things handle @problems changes elsewhere
|
|
@ace[if problems.length then 'setStyle' else 'unsetStyle'] 'user-code-problem'
|
|
@ace[if isCast then 'setStyle' else 'unsetStyle'] 'spell-cast' # Does this still do anything?
|
|
|
|
saveUserCodeProblem: (aether, aetherProblem) ->
|
|
# Skip duplicate problems
|
|
hashValue = aether.raw + aetherProblem.message
|
|
return if hashValue of @savedProblems
|
|
@savedProblems[hashValue] = true
|
|
return unless Math.random() < 0.01 # Let's only save a tiny fraction of these during HoC to reduce writes.
|
|
|
|
# Save new problem
|
|
@userCodeProblem = new UserCodeProblem()
|
|
@userCodeProblem.set 'code', aether.raw
|
|
if aetherProblem.range
|
|
rawLines = aether.raw.split '\n'
|
|
errorLines = rawLines.slice aetherProblem.range[0].row, aetherProblem.range[1].row + 1
|
|
@userCodeProblem.set 'codeSnippet', errorLines.join '\n'
|
|
@userCodeProblem.set 'errHint', aetherProblem.hint if aetherProblem.hint
|
|
@userCodeProblem.set 'errId', aetherProblem.id if aetherProblem.id
|
|
@userCodeProblem.set 'errLevel', aetherProblem.level if aetherProblem.level
|
|
if aetherProblem.message
|
|
@userCodeProblem.set 'errMessage', aetherProblem.message
|
|
# Save error message without 'Line N: ' prefix
|
|
messageNoLineInfo = aetherProblem.message
|
|
if lineInfoMatch = messageNoLineInfo.match /^Line [0-9]+\: /
|
|
messageNoLineInfo = messageNoLineInfo.slice(lineInfoMatch[0].length)
|
|
@userCodeProblem.set 'errMessageNoLineInfo', messageNoLineInfo
|
|
@userCodeProblem.set 'errRange', aetherProblem.range if aetherProblem.range
|
|
@userCodeProblem.set 'errType', aetherProblem.type if aetherProblem.type
|
|
@userCodeProblem.set 'language', aether.language.id if aether.language?.id
|
|
@userCodeProblem.set 'levelID', @options.levelID if @options.levelID
|
|
@userCodeProblem.save()
|
|
null
|
|
|
|
# Autocast (preload the world in the background):
|
|
# Goes immediately if the code is a) changed and b) complete/valid and c) the cursor is at beginning or end of a line
|
|
# We originally thought it would:
|
|
# - Go after specified delay if a) and b) but not c)
|
|
# - Go only when manually cast or deselecting a Thang when there are errors
|
|
# But the error message display was delayed, so now trying:
|
|
# - Go after specified delay if a) and not b) or c)
|
|
guessWhetherFinished: (aether) ->
|
|
valid = not aether.getAllProblems().length
|
|
return unless valid
|
|
cursorPosition = @ace.getCursorPosition()
|
|
currentLine = _.string.rtrim(@aceDoc.$lines[cursorPosition.row].replace(@singleLineCommentRegex(), '')) # trim // unless inside "
|
|
endOfLine = cursorPosition.column >= currentLine.length # just typed a semicolon or brace, for example
|
|
beginningOfLine = not currentLine.substr(0, cursorPosition.column).trim().length # uncommenting code, for example
|
|
incompleteThis = /^(s|se|sel|self|t|th|thi|this)$/.test currentLine.trim()
|
|
#console.log "finished=#{valid and (endOfLine or beginningOfLine) and not incompleteThis}", valid, endOfLine, beginningOfLine, incompleteThis, cursorPosition, currentLine.length, aether, new Date() - 0, currentLine
|
|
if not incompleteThis and @options.level.isType('game-dev')
|
|
# TODO: Improve gamedev autocast speed
|
|
@spell.transpile @getSource()
|
|
@cast(false, false, true)
|
|
else if (endOfLine or beginningOfLine) and not incompleteThis
|
|
@preload()
|
|
|
|
singleLineCommentRegex: ->
|
|
if @_singleLineCommentRegex
|
|
@_singleLineCommentRegex.lastIndex = 0
|
|
return @_singleLineCommentRegex
|
|
if @spell.language is 'html'
|
|
commentStart = "#{commentStarts.html}|#{commentStarts.css}|#{commentStarts.javascript}"
|
|
else
|
|
commentStart = commentStarts[@spell.language] or '//'
|
|
@_singleLineCommentRegex = new RegExp "[ \t]*(#{commentStart})[^\"'\n]*"
|
|
@_singleLineCommentRegex
|
|
|
|
singleLineCommentOnlyRegex: ->
|
|
if @_singleLineCommentOnlyRegex
|
|
@_singleLineCommentOnlyRegex.lastIndex = 0
|
|
return @_singleLineCommentOnlyRegex
|
|
@_singleLineCommentOnlyRegex = new RegExp( '^' + @singleLineCommentRegex().source)
|
|
@_singleLineCommentOnlyRegex
|
|
|
|
commentOutMyCode: ->
|
|
prefix = if @spell.language is 'javascript' then 'return; ' else 'return '
|
|
comment = prefix + commentStarts[@spell.language]
|
|
|
|
preload: ->
|
|
# Send this code over to the God for preloading, but don't change the cast state.
|
|
#console.log 'preload?', @spell.source.indexOf('while'), @spell.source.length, @spellThang?.castAether?.metrics?.statementsExecuted
|
|
return if @spell.source.indexOf('while') isnt -1 # If they're working with while-loops, it's more likely to be an incomplete infinite loop, so don't preload.
|
|
return if @spell.source.length > 500 # Only preload on really short methods
|
|
return if @spellThang?.castAether?.metrics?.statementsExecuted > 2000 # Don't preload if they are running significant amounts of user code
|
|
return if @options.level.isType('web-dev')
|
|
oldSource = @spell.source
|
|
oldSpellThangAether = @spell.thang?.aether.serialize()
|
|
@spell.transpile @getSource()
|
|
@cast true
|
|
@spell.source = oldSource
|
|
for key, value of oldSpellThangAether
|
|
@spell.thang.aether[key] = value
|
|
|
|
onSpellChanged: (e) ->
|
|
# TODO: Merge with updateHTML
|
|
@spellHasChanged = true
|
|
|
|
onSessionWillSave: (e) ->
|
|
return unless @spellHasChanged and me.isAdmin()
|
|
setTimeout(=>
|
|
unless @destroyed or @spellHasChanged
|
|
@$el.find('.save-status').finish().show().fadeOut(2000)
|
|
, 1000)
|
|
@spellHasChanged = false
|
|
|
|
onUserCodeProblem: (e) ->
|
|
return unless e.god is @options.god
|
|
return @onInfiniteLoop e if e.problem.id is 'runtime_InfiniteLoop'
|
|
return unless e.problem.userInfo.methodName is @spell.name
|
|
return unless @spell.thang?.thang.id is e.problem.userInfo.thangID
|
|
@spell.hasChangedSignificantly @getSource(), null, (hasChanged) =>
|
|
return if hasChanged
|
|
@spell.thang.aether.addProblem e.problem
|
|
@lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile
|
|
@updateAether false, false
|
|
|
|
onNonUserCodeProblem: (e) ->
|
|
return unless e.god is @options.god
|
|
return unless @spellThang
|
|
problem = @spellThang.aether.createUserCodeProblem type: 'runtime', kind: 'Unhandled', message: "Unhandled error: #{e.problem.message}"
|
|
@spellThang.aether.addProblem problem
|
|
@spellThang.castAether?.addProblem problem
|
|
@lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile
|
|
@updateAether false, false # TODO: doesn't work, error doesn't display
|
|
|
|
onInfiniteLoop: (e) ->
|
|
return unless @spellThang
|
|
@spellThang.aether.addProblem e.problem
|
|
@spellThang.castAether?.addProblem e.problem
|
|
@lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile
|
|
@updateAether false, false
|
|
|
|
onNewWorld: (e) ->
|
|
if thang = e.world.getThangByID @spell.thang?.thang.id
|
|
aether = e.world.userCodeMap[thang.id]?[@spell.name]
|
|
@spell.thang.castAether = aether
|
|
@spell.thang.aether = @spell.createAether thang
|
|
#console.log thang.id, @spell.spellKey, 'ran', aether.metrics.callsExecuted, 'times over', aether.metrics.statementsExecuted, 'statements, with max recursion depth', aether.metrics.maxDepth, 'and full flow/metrics', aether.metrics, aether.flow
|
|
else
|
|
@spell.thang = null
|
|
|
|
@spell.transpile() # TODO: is there any way we can avoid doing this if it hasn't changed? Causes a slight hang.
|
|
@updateAether false, false
|
|
|
|
# --------------------------------------------------------------------------------------------------
|
|
|
|
focus: ->
|
|
# TODO: it's a hack checking if a modal is visible; the events should be removed somehow
|
|
# but this view is not part of the normal subview destroying because of how it's swapped
|
|
return unless @controlsEnabled and @writable and $('.modal:visible').length is 0
|
|
return if @ace.isFocused()
|
|
@ace.focus()
|
|
@ace.clearSelection()
|
|
|
|
onFrameChanged: (e) ->
|
|
return unless @spellThang and e.selectedThang?.id is @spellThang?.thang.id
|
|
@thang = e.selectedThang # update our thang to the current version
|
|
@highlightCurrentLine()
|
|
|
|
onCoordinateSelected: (e) ->
|
|
return unless @ace.isFocused() and e.x? and e.y?
|
|
if @spell.language is 'python'
|
|
@ace.insert "{\"x\": #{e.x}, \"y\": #{e.y}}"
|
|
else if @spell.language is 'lua'
|
|
@ace.insert "{x=#{e.x}, y=#{e.y}}"
|
|
else
|
|
@ace.insert "{x: #{e.x}, y: #{e.y}}"
|
|
@highlightCurrentLine()
|
|
|
|
onStatementIndexUpdated: (e) ->
|
|
return unless e.ace is @ace
|
|
@highlightCurrentLine()
|
|
|
|
highlightCurrentLine: (flow) =>
|
|
# TODO: move this whole thing into SpellDebugView or somewhere?
|
|
@highlightEntryPoints() unless @destroyed
|
|
flow ?= @spellThang?.castAether?.flow
|
|
return unless flow and @thang
|
|
executed = []
|
|
executedRows = {}
|
|
matched = false
|
|
states = flow.states ? []
|
|
currentCallIndex = null
|
|
for callState, callNumber in states
|
|
if not currentCallIndex? and callState.userInfo?.time > @thang.world.age
|
|
currentCallIndex = callNumber - 1
|
|
if matched
|
|
executed.pop()
|
|
break
|
|
executed.push []
|
|
for state, statementNumber in callState.statements
|
|
if state.userInfo?.time > @thang.world.age
|
|
matched = true
|
|
break
|
|
_.last(executed).push state
|
|
executedRows[state.range[0].row] = true
|
|
#state.executing = true if state.userInfo?.time is @thang.world.age # no work
|
|
currentCallIndex ?= callNumber - 1
|
|
#console.log 'got call index', currentCallIndex, 'for time', @thang.world.age, 'out of', states.length
|
|
|
|
@decoratedGutter = @decoratedGutter || {}
|
|
|
|
# TODO: don't redo the markers if they haven't actually changed
|
|
for markerRange in (@markerRanges ?= [])
|
|
markerRange.start.detach()
|
|
markerRange.end.detach()
|
|
@aceSession.removeMarker markerRange.id
|
|
@markerRanges = []
|
|
for row in [0 ... @aceSession.getLength()]
|
|
unless executedRows[row]
|
|
@aceSession.removeGutterDecoration row, 'executing'
|
|
@aceSession.removeGutterDecoration row, 'executed'
|
|
@decoratedGutter[row] = ''
|
|
lastExecuted = _.last executed
|
|
showToolbarView = executed.length and @spellThang.castAether.metrics.statementsExecuted > 3 and not @options.level.get 'hidesCodeToolbar' # Hide for a while
|
|
showToolbarView = false # TODO: fix toolbar styling in new design to have some space for it
|
|
|
|
if showToolbarView
|
|
statementIndex = Math.max 0, lastExecuted.length - 1
|
|
@toolbarView?.toggleFlow true
|
|
@toolbarView?.setCallState states[currentCallIndex], statementIndex, currentCallIndex, @spellThang.castAether.metrics
|
|
lastExecuted = lastExecuted[0 .. @toolbarView.statementIndex] if @toolbarView?.statementIndex?
|
|
else
|
|
@toolbarView?.toggleFlow false
|
|
@debugView?.setVariableStates {}
|
|
marked = {}
|
|
gotVariableStates = false
|
|
for state, i in lastExecuted ? []
|
|
[start, end] = state.range
|
|
clazz = if i is lastExecuted.length - 1 then 'executing' else 'executed'
|
|
if clazz is 'executed'
|
|
continue if marked[start.row]
|
|
marked[start.row] = true
|
|
markerType = 'fullLine'
|
|
else
|
|
@debugView?.setVariableStates state.variables
|
|
gotVariableStates = true
|
|
markerType = 'text'
|
|
markerRange = new Range start.row, start.col, end.row, end.col
|
|
markerRange.start = @aceDoc.createAnchor markerRange.start
|
|
markerRange.end = @aceDoc.createAnchor markerRange.end
|
|
markerRange.id = @aceSession.addMarker markerRange, clazz, markerType
|
|
@markerRanges.push markerRange
|
|
if executedRows[start.row] and @decoratedGutter[start.row] isnt clazz
|
|
@aceSession.removeGutterDecoration start.row, @decoratedGutter[start.row] if @decoratedGutter[start.row] isnt ''
|
|
@aceSession.addGutterDecoration start.row, clazz
|
|
@decoratedGutter[start.row] = clazz
|
|
Backbone.Mediator.publish("tome:highlight-line", line:start.row) if application.isIPadApp
|
|
@debugView?.setVariableStates {} unless gotVariableStates
|
|
null
|
|
|
|
highlightEntryPoints: ->
|
|
# Put a yellow arrow in the gutter pointing to each place we expect them to put in code.
|
|
# Usually, this is indicated by a blank line after a comment line, except for the first comment lines.
|
|
# If we need to indicate an entry point on a line that has code, we use ∆ in a comment on that line.
|
|
# If the entry point line has been changed (beyond the most basic shifted lines), we don't point it out.
|
|
lines = @aceDoc.$lines
|
|
originalLines = @spell.originalSource.split '\n'
|
|
session = @aceSession
|
|
commentStart = commentStarts[@spell.language] or '//'
|
|
seenAnEntryPoint = false
|
|
previousLine = null
|
|
previousLineHadComment = false
|
|
previousLineHadCode = false
|
|
previousLineWasBlank = false
|
|
pastIntroComments = false
|
|
for line, index in lines
|
|
session.removeGutterDecoration index, 'entry-point'
|
|
session.removeGutterDecoration index, 'next-entry-point'
|
|
session.removeGutterDecoration index, "entry-point-indent-#{i}" for i in [0, 4, 8, 12, 16]
|
|
|
|
lineHasComment = @singleLineCommentRegex().test line
|
|
lineHasCode = line.trim()[0] and not @singleLineCommentOnlyRegex().test line
|
|
lineIsBlank = /^[ \t]*$/.test line
|
|
lineHasExplicitMarker = line.indexOf('∆') isnt -1
|
|
|
|
originalLine = originalLines[index]
|
|
lineHasChanged = line isnt originalLine
|
|
|
|
isEntryPoint = lineIsBlank and previousLineHadComment and not previousLineHadCode and pastIntroComments
|
|
if isEntryPoint and lineHasChanged
|
|
# It might just be that the line was shifted around by the player inserting more code.
|
|
# We also look for the unchanged comment line in a new position to find what line we're really on.
|
|
movedIndex = originalLines.indexOf previousLine
|
|
if movedIndex isnt -1 and line is originalLines[movedIndex + 1]
|
|
lineHasChanged = false
|
|
else
|
|
isEntryPoint = false
|
|
|
|
if lineHasExplicitMarker
|
|
if lineHasChanged
|
|
if originalLines.indexOf(line) isnt -1
|
|
lineHasChanged = false
|
|
isEntryPoint = true
|
|
else
|
|
isEntryPoint = true
|
|
|
|
if isEntryPoint
|
|
session.addGutterDecoration index, 'entry-point'
|
|
unless seenAnEntryPoint
|
|
session.addGutterDecoration index, 'next-entry-point'
|
|
seenAnEntryPoint = true
|
|
|
|
# Shift pointer right based on current indentation
|
|
# TODO: tabs probably need different horizontal offsets than spaces
|
|
indent = 0
|
|
indent++ while /\s/.test(line[indent])
|
|
indent = Math.min(16, Math.floor(indent / 4) * 4)
|
|
session.addGutterDecoration index, "entry-point-indent-#{indent}"
|
|
|
|
previousLine = line
|
|
previousLineHadComment = lineHasComment
|
|
previousLineHadCode = lineHasCode
|
|
previousLineWasBlank = lineIsBlank
|
|
pastIntroComments ||= lineHasCode or previousLineWasBlank
|
|
|
|
onAnnotationClick: ->
|
|
# @ is the gutter element
|
|
Backbone.Mediator.publish 'tome:jiggle-problem-alert', {}
|
|
|
|
onGutterClick: =>
|
|
@ace.clearSelection()
|
|
|
|
onDisableControls: (e) -> @toggleControls e, false
|
|
onEnableControls: (e) -> @toggleControls e, @writable
|
|
toggleControls: (e, enabled) ->
|
|
return if @destroyed
|
|
return if e?.controls and not ('editor' in e.controls)
|
|
return if enabled is @controlsEnabled
|
|
@controlsEnabled = enabled and @writable
|
|
disabled = not enabled
|
|
wasFocused = @ace.isFocused()
|
|
@ace.setReadOnly disabled
|
|
@ace[if disabled then 'setStyle' else 'unsetStyle'] 'disabled'
|
|
@toggleBackground()
|
|
$('body').focus() if disabled and wasFocused
|
|
|
|
toggleBackground: =>
|
|
# TODO: make the background an actual background and do the CSS trick
|
|
# used in spell-top-bar-view.sass for disabling
|
|
background = @$el.find('img.code-background')[0]
|
|
if background.naturalWidth is 0 # not loaded yet
|
|
return _.delay @toggleBackground, 100
|
|
filters.revertImage background, 'span.code-background' if @controlsEnabled
|
|
filters.darkenImage background, 'span.code-background', 0.8 unless @controlsEnabled
|
|
|
|
onSpellBeautify: (e) ->
|
|
return unless @spellThang and (@ace.isFocused() or e.spell is @spell)
|
|
ugly = @getSource()
|
|
pretty = @spellThang.aether.beautify(ugly.replace /\bloop\b/g, 'while (__COCO_LOOP_CONSTRUCT__)').replace /while \(__COCO_LOOP_CONSTRUCT__\)/g, 'loop'
|
|
@ace.setValue pretty
|
|
|
|
onMaximizeToggled: (e) ->
|
|
_.delay (=> @resize()), 500 + 100 # Wait $level-resize-transition-time, plus a bit.
|
|
|
|
onWindowResize: (e) =>
|
|
@spellPaletteHeight = null
|
|
$('#spell-palette-view').css 'height', 'auto' # Let it go back to controlling its own height
|
|
_.delay (=> @resize?()), 500 + 100 # Wait $level-resize-transition-time, plus a bit.
|
|
|
|
resize: ->
|
|
@ace?.resize true
|
|
@lastScreenLineCount = null
|
|
@updateLines()
|
|
|
|
onChangeEditorConfig: (e) ->
|
|
aceConfig = me.get('aceConfig') ? {}
|
|
@ace.setDisplayIndentGuides aceConfig.indentGuides # default false
|
|
@ace.setShowInvisibles aceConfig.invisibles # default false
|
|
@ace.setKeyboardHandler @keyBindings[aceConfig.keyBindings ? 'default']
|
|
@updateAutocomplete(aceConfig.liveCompletion ? false)
|
|
|
|
onChangeLanguage: (e) ->
|
|
return unless @spell.canWrite()
|
|
@aceSession.setMode utils.aceEditModes[e.language]
|
|
@autocomplete?.set 'language', utils.aceEditModes[e.language].substr('ace/mode/')
|
|
wasDefault = @getSource() is @spell.originalSource
|
|
@spell.setLanguage e.language
|
|
@reloadCode true if wasDefault
|
|
|
|
onInsertSnippet: (e) ->
|
|
snippetCode = null
|
|
if e.doc.snippets?[e.language]?.code
|
|
snippetCode = e.doc.snippets[e.language].code
|
|
else if (e.formatted.type isnt 'snippet') and e.formatted.shortName?
|
|
snippetCode = e.formatted.shortName
|
|
return unless snippetCode?
|
|
snippetManager = ace.require('ace/snippets').snippetManager
|
|
snippetManager.insertSnippet @ace, snippetCode
|
|
return
|
|
|
|
dismiss: ->
|
|
@spell.hasChangedSignificantly @getSource(), null, (hasChanged) =>
|
|
@recompile() if hasChanged
|
|
|
|
onScriptStateChange: (e) ->
|
|
@scriptRunning = if e.currentScript is null then false else true
|
|
|
|
onPlaybackEndedChanged: (e) ->
|
|
$(@ace?.container).toggleClass 'playback-ended', e.ended
|
|
|
|
checkRequiredCode: =>
|
|
return if @destroyed
|
|
source = @getSource().replace @singleLineCommentRegex(), ''
|
|
requiredCodeFragments = @options.level.get 'requiredCode'
|
|
for requiredCodeFragment in requiredCodeFragments
|
|
# Could make this obey regular expressions like suspectCode if needed
|
|
if source.indexOf(requiredCodeFragment) is -1
|
|
@warnedCodeFragments ?= {}
|
|
unless @warnedCodeFragments[requiredCodeFragment]
|
|
Backbone.Mediator.publish 'tome:required-code-fragment-deleted', codeFragment: requiredCodeFragment
|
|
@warnedCodeFragments[requiredCodeFragment] = true
|
|
|
|
checkSuspectCode: =>
|
|
return if @destroyed
|
|
source = @getSource().replace @singleLineCommentRegex(), ''
|
|
suspectCodeFragments = @options.level.get 'suspectCode'
|
|
detectedSuspectCodeFragmentNames = []
|
|
for suspectCodeFragment in suspectCodeFragments
|
|
pattern = new RegExp suspectCodeFragment.pattern, 'm'
|
|
if pattern.test source
|
|
@warnedCodeFragments ?= {}
|
|
unless @warnedCodeFragments[suspectCodeFragment.name]
|
|
Backbone.Mediator.publish 'tome:suspect-code-fragment-added', codeFragment: suspectCodeFragment.name, codeLanguage: @spell.language
|
|
@warnedCodeFragments[suspectCodeFragment.name] = true
|
|
detectedSuspectCodeFragmentNames.push suspectCodeFragment.name
|
|
for lastDetectedSuspectCodeFragmentName in @lastDetectedSuspectCodeFragmentNames ? []
|
|
unless lastDetectedSuspectCodeFragmentName in detectedSuspectCodeFragmentNames
|
|
Backbone.Mediator.publish 'tome:suspect-code-fragment-deleted', codeFragment: lastDetectedSuspectCodeFragmentName, codeLanguage: @spell.language
|
|
@lastDetectedSuspectCodeFragmentNames = detectedSuspectCodeFragmentNames
|
|
|
|
destroy: ->
|
|
$(@ace?.container).find('.ace_gutter').off 'click', '.ace_error, .ace_warning, .ace_info', @onAnnotationClick
|
|
$(@ace?.container).find('.ace_gutter').off 'click', @onGutterClick
|
|
@firepad?.dispose()
|
|
@ace?.commands.removeCommand command for command in @aceCommands
|
|
@ace?.destroy()
|
|
@aceDoc?.off 'change', @onCodeChangeMetaHandler
|
|
@aceSession?.selection.off 'changeCursor', @onCursorActivity
|
|
@destroyAceEditor(@ace)
|
|
@debugView?.destroy()
|
|
@translationView?.destroy()
|
|
@toolbarView?.destroy()
|
|
@autocomplete?.addSnippets [], @editorLang if @editorLang?
|
|
$(window).off 'resize', @onWindowResize
|
|
window.clearTimeout @saveSpadeTimeout
|
|
@saveSpadeTimeout = null
|
|
super()
|
|
|
|
# Note: These need to be double-escaped for insertion into regexes
|
|
commentStarts =
|
|
javascript: '//'
|
|
python: '#'
|
|
coffeescript: '#'
|
|
lua: '--'
|
|
java: '//'
|
|
html: '<!--'
|
|
css: '/\\*'
|