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. .' @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 """
""" 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