overleaf/services/web/public/coffee/editor/Editor.coffee

374 lines
11 KiB
CoffeeScript

define [
"editor/Document"
"undo/UndoManager"
"utils/Modal"
"ace/ace"
"ace/edit_session"
"ace/mode/latex"
"ace/range"
"ace/keyboard/vim"
"ace/keyboard/emacs"
"libs/backbone"
"libs/jquery.storage"
], (Document, UndoManager, Modal) ->
AceEditor = require("ace/ace")
EditSession = require('ace/edit_session').EditSession
LatexMode = require("ace/mode/latex").Mode
Range = require("ace/range").Range
Vim = require("ace/keyboard/vim").handler
Emacs = require("ace/keyboard/emacs").handler
keybindings = ace: null, vim: Vim, emacs: Emacs
class Editor
templates:
editorPanel: $("#editorPanelTemplate").html()
loadingIndicator: $("#loadingIndicatorTemplate").html()
viewOptions: {flatView:"flatView", splitView:"splitView"}
currentViewState: undefined
compilationErrors: {}
constructor: (@ide) ->
_.extend @, Backbone.Events
@editorPanel = $(@templates.editorPanel)
@ide.mainAreaManager.addArea
identifier: "editor"
element: @editorPanel
@initializeEditor()
@bindToFileTreeEvents()
@enable()
@loadingIndicator = $(@templates.loadingIndicator)
@editorPanel.find("#editor").append(@loadingIndicator)
@leftPanel = @editorPanel.find("#leftEditorPanel")
@rightPanel = @editorPanel.find("#rightEditorPanel")
@initSplitView()
@switchToFlatView()
bindToFileTreeEvents: () ->
@ide.fileTreeManager.on "open:doc", (doc_id, options = {}) =>
if @enabled
@openDoc doc_id, options
initSplitView: () ->
@$splitter = splitter = @editorPanel.find("#editorSplitter")
options =
spacing_open: 8
spacing_closed: 16
east:
size: "50%"
maskIframesOnResize: true
onresize: () =>
@trigger("resize")
if (state = $.localStorage("layout.editor"))?
options.east = state.east
splitter.layout options
$(window).unload () =>
@_saveSplitterState()
_saveSplitterState: () ->
if $("#editorSplitter").is(":visible")
state = $("#editorSplitter").layout().readState()
eastWidth = state.east.size + $("#editorSplitter .ui-layout-resizer-east").width()
percentWidth = eastWidth / $("#editorSplitter").width() * 100 + "%"
state.east.size = percentWidth
$.localStorage("layout.editor", state)
switchToSplitView: () ->
if @currentViewState != @viewOptions.splitView
@currentViewState = @viewOptions.splitView
@leftPanel.prepend(
@editorPanel.find("#editorWrapper")
)
splitter = @editorPanel.find("#editorSplitter")
splitter.show()
@ide.layoutManager.resizeAllSplitters()
switchToFlatView: () ->
if @currentViewState != @viewOptions.flatView
@_saveSplitterState()
@currentViewState = @viewOptions.flatView
@editorPanel.prepend(
@editorPanel.find("#editorWrapper")
)
@editorPanel.find("#editorSplitter").hide()
@aceEditor.resize(true)
showLoading: () ->
delay = 600 # ms
@loading = true
setTimeout ( =>
if @loading
@loadingIndicator.show()
), delay
hideLoading: () ->
@loading = false
@loadingIndicator.hide()
showUndoConflictWarning: () ->
$("#editor").prepend($("#undoConflictWarning"))
$("#undoConflictWarning").show()
hideBtn = $("#undoConflictWarning .js-hide")
hideBtn.off("click")
hideBtn.on "click", (e) ->
e.preventDefault()
$("#undoConflictWarning").hide()
if @hideUndoWarningTimeout?
clearTimeout @hideUndoWarningTimeout
delete @hideUndoWarningTimeout
@hideUndoWarningTimeout = setTimeout ->
$("#undoConflictWarning").fadeOut("slow")
, 4000
initializeEditor: () ->
@aceEditor = aceEditor = AceEditor.edit("editor")
@on "resize", => @aceEditor.resize()
@ide.layoutManager.on "resize", => @trigger "resize"
mode = window.userSettings.mode
theme = window.userSettings.theme
chosenKeyBindings = keybindings[mode]
aceEditor.setKeyboardHandler(chosenKeyBindings)
aceEditor.setTheme("ace/theme/#{window.userSettings.theme}")
aceEditor.setShowPrintMargin(false)
# Prevert Ctrl|Cmd-S from triggering save dialog
aceEditor.commands.addCommand
name: "save",
bindKey: win: "Ctrl-S", mac: "Command-S"
exec: () ->
readOnly: true
aceEditor.commands.removeCommand "transposeletters"
aceEditor.commands.removeCommand "showSettingsMenu"
aceEditor.commands.removeCommand "foldall"
aceEditor.showCommandLine = (args...) =>
@trigger "showCommandLine", aceEditor, args...
aceEditor.on "dblclick", (e) => @trigger "dblclick", e
aceEditor.on "click", (e) => @trigger "click", e
aceEditor.on "mousemove", (e) =>
position = @aceEditor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
e.position = position
@trigger "mousemove", e
setIdeToEditorPanel: (options = {}) ->
@aceEditor.focus()
@aceEditor.resize()
loadDocument = =>
@refreshCompilationErrors()
@ide.layoutManager.resizeAllSplitters()
if options.line?
@gotoLine(options.line)
else
pos = $.localStorage("doc.position.#{@current_doc_id}") || {}
@ignoreCursorPositionChanges = true
@setCursorPosition(pos.cursorPosition or {row: 0, column: 0})
@setScrollTop(pos.scrollTop or 0)
@ignoreCursorPositionChanges = false
@ide.mainAreaManager.change 'editor', =>
setTimeout loadDocument, 0
refreshCompilationErrors: () ->
@getSession().setAnnotations @compilationErrors[@current_doc_id]
openDoc: (doc_id, options = {}) ->
if @current_doc_id == doc_id && !options.forceReopen
@setIdeToEditorPanel(line: options.line)
else
@showLoading()
@current_doc_id = doc_id
@_openNewDocument doc_id, (error, document) =>
if error?
@ide.showGenericServerErrorMessage()
return
@setIdeToEditorPanel(line: options.line)
@hideLoading()
@trigger "change:doc", @getSession()
_openNewDocument: (doc_id, callback = (error, document) ->) ->
if @document?
@document.leaveAndCleanUp()
@_unbindFromDocumentEvents(@document)
@_detachDocumentFromEditor(@document)
@document = Document.getDocument @ide, doc_id
@document.join (error) =>
return callback(error) if error?
@_bindToDocumentEvents(@document)
@_bindDocumentToEditor(@document)
callback null, @document
_bindToDocumentEvents: (document) ->
document.on "remoteop", () =>
@undoManager.nextUpdateIsRemote = true
document.on "error", (error) =>
@openDoc(document.doc_id, forceReopen: true)
Modal.createModal
title: "Out of sync"
message: "Sorry, this file has gone out of sync and we need to do a full refresh. Please let us know if this happens frequently."
buttons:[
text: "Ok"
]
document.on "externalUpdate", () =>
Modal.createModal
title: "Document Updated Externally"
message: "This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions please look in the history."
buttons:[
text: "Ok"
]
_unbindFromDocumentEvents: (document) ->
document.off()
_bindDocumentToEditor: (document) ->
$("#editor").show()
@_bindNewDocToAce(document)
_detachDocumentFromEditor: (document) ->
document.detachFromAce()
_bindNewDocToAce: (document) ->
@_createNewSessionFromDocLines(document.getSnapshot().split("\n"))
@_setReadWritePermission()
@_bindToAceEvents()
# Updating the doc can cause the cursor to jump around
# but we shouldn't record that
@ignoreCursorPositionChanges = true
document.attachToAce(@aceEditor)
@ignoreCursorPositionChanges = false
_bindToAceEvents: () ->
aceDoc = @getDocument()
aceDoc.on 'change', (change) => @onDocChange(change)
session = @getSession()
session.on "changeScrollTop", (e) => @onScrollTopChange(e)
session.selection.on 'changeCursor', (e) => @onCursorChange(e)
_createNewSessionFromDocLines: (docLines) ->
@aceEditor.setSession(new EditSession(docLines))
session = @getSession()
session.setUseWrapMode(true)
session.setMode(new LatexMode())
@undoManager = new UndoManager(@)
session.setUndoManager @undoManager
_setReadWritePermission: () ->
if !@ide.isAllowedToDoIt 'readAndWrite'
@makeReadOnly()
else
@makeWritable()
onDocChange: (change) ->
@lastUpdated = new Date()
@trigger "update:doc", change
onScrollTopChange: (event) ->
@trigger "scroll", event
if !@ignoreCursorPositionChanges
docPosition = $.localStorage("doc.position.#{@current_doc_id}") || {}
docPosition.scrollTop = @getScrollTop()
$.localStorage("doc.position.#{@current_doc_id}", docPosition)
onCursorChange: (event) ->
@trigger "cursor:change", event
if !@ignoreCursorPositionChanges
docPosition = $.localStorage("doc.position.#{@current_doc_id}") || {}
docPosition.cursorPosition = @getCursorPosition()
$.localStorage("doc.position.#{@current_doc_id}", docPosition)
makeReadOnly: () ->
@aceEditor.setReadOnly true
makeWritable: () ->
@aceEditor.setReadOnly false
getSession: () -> @aceEditor.getSession()
getDocument: () -> @getSession().getDocument()
gotoLine: (line) ->
@aceEditor.gotoLine(line)
getCurrentLine: () ->
@aceEditor.selection?.getCursor()?.row
getCurrentColumn: () ->
@aceEditor.selection?.getCursor()?.column
getLines: (from, to) ->
if from? and to?
@getSession().doc.getLines(from, to)
else
@getSession().doc.getAllLines()
addMarker: (position, klass, type, inFront) ->
range = new Range(
position.row, position.column,
position.row, position.column + position.length
)
@getSession().addMarker range, klass, type, inFront
removeMarker: (markerId) ->
@getSession().removeMarker markerId
getCursorPosition: () -> @aceEditor.getCursorPosition()
setCursorPosition: (pos) -> @aceEditor.moveCursorToPosition(pos)
getScrollTop: () -> @getSession().getScrollTop()
setScrollTop: (pos) -> @getSession().setScrollTop(pos)
replaceText: (range, text) ->
@getSession().replace(new Range(
range.start.row, range.start.column,
range.end.row, range.end.column
), text)
getContainerElement: () ->
$(@aceEditor.renderer.getContainerElement())
getCursorElement: () ->
@getContainerElement().find(".ace_cursor")
textToEditorCoordinates: (x, y) ->
editorAreaOffset = @getContainerElement().offset()
{pageX, pageY} = @aceEditor.renderer.textToScreenCoordinates(x, y)
return {
x: pageX - editorAreaOffset.left
y: pageY - editorAreaOffset.top
}
chaosMonkey: (line = 0, char = "a") ->
@_cm = setInterval () =>
@aceEditor.session.insert({row: line, column: 0}, char)
, 100
clearChaosMonkey: () ->
clearInterval @_cm
getCurrentDocId: () ->
@current_doc_id
enable: () ->
@enabled = true
disable: () ->
@enabled = false
hasUnsavedChanges: () ->
Document.hasUnsavedChanges()