overleaf/services/web/public/coffee/ide/editor/Document.coffee

394 lines
13 KiB
CoffeeScript
Raw Normal View History

2014-07-08 07:02:26 -04:00
define [
"utils/EventEmitter"
"ide/editor/ShareJsDoc"
"ide/review-panel/RangesTracker"
], (EventEmitter, ShareJsDoc, RangesTracker) ->
2014-07-08 07:02:26 -04:00
class Document extends EventEmitter
@getDocument: (ide, doc_id) ->
@openDocs ||= {}
if !@openDocs[doc_id]?
2016-05-27 09:14:08 -04:00
sl_console.log "[getDocument] Creating new document instance for #{doc_id}"
2014-07-08 07:02:26 -04:00
@openDocs[doc_id] = new Document(ide, doc_id)
2016-05-27 09:14:08 -04:00
else
sl_console.log "[getDocument] Returning existing document instance for #{doc_id}"
2014-07-08 07:02:26 -04:00
return @openDocs[doc_id]
@hasUnsavedChanges: () ->
for doc_id, doc of (@openDocs or {})
return true if doc.hasBufferedOps()
return false
@flushAll: () ->
for doc_id, doc of @openDocs
doc.flush()
2014-07-08 07:02:26 -04:00
constructor: (@ide, @doc_id) ->
@connected = @ide.socket.socket.connected
@joined = false
@wantToBeJoined = false
@_checkConsistency = _.bind(@_checkConsistency, @)
@inconsistentCount = 0
@_bindToEditorEvents()
@_bindToSocketEvents()
attachToAce: (@ace) ->
@doc?.attachToAce(@ace)
editorDoc = @ace.getSession().getDocument()
editorDoc.on "change", @_checkConsistency
2017-05-24 05:07:14 -04:00
@ide.$scope.$emit 'document:opened', @doc
2014-07-08 07:02:26 -04:00
detachFromAce: () ->
@doc?.detachFromAce()
editorDoc = @ace?.getSession().getDocument()
editorDoc?.off "change", @_checkConsistency
@ide.$scope.$emit 'document:closed', @doc
2016-12-13 12:57:46 -05:00
submitOp: (args...) -> @doc?.submitOp(args...)
2014-07-08 07:02:26 -04:00
_checkConsistency: () ->
# We've been seeing a lot of errors when I think there shouldn't be
# any, which may be related to this check happening before the change is
# applied. If we use a timeout, hopefully we can reduce this.
setTimeout () =>
editorValue = @ace?.getValue()
sharejsValue = @doc?.getSnapshot()
if editorValue != sharejsValue
@inconsistentCount++
else
@inconsistentCount = 0
if @inconsistentCount >= 3
@_onError new Error("Editor text does not match server text")
, 0
getSnapshot: () ->
@doc?.getSnapshot()
getType: () ->
@doc?.getType()
getInflightOp: () ->
@doc?.getInflightOp()
getPendingOp: () ->
@doc?.getPendingOp()
getRecentAck: () ->
@doc?.getRecentAck()
getOpSize: (op) ->
@doc?.getOpSize(op)
2014-07-08 07:02:26 -04:00
hasBufferedOps: () ->
@doc?.hasBufferedOps()
setTrackingChanges: (track_changes) ->
@doc.track_changes = track_changes
2017-01-09 04:49:03 -05:00
getTrackingChanges: () ->
!!@doc.track_changes
2017-01-09 04:49:03 -05:00
setTrackChangesIdSeeds: (id_seeds) ->
@doc.track_changes_id_seeds = id_seeds
2014-07-08 07:02:26 -04:00
_bindToSocketEvents: () ->
@_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
@ide.socket.on "otUpdateApplied", @_onUpdateAppliedHandler
@_onErrorHandler = (error, update) => @_onError(error, update)
@ide.socket.on "otUpdateError", @_onErrorHandler
@_onDisconnectHandler = (error) => @_onDisconnect(error)
@ide.socket.on "disconnect", @_onDisconnectHandler
_bindToEditorEvents: () ->
onReconnectHandler = (update) =>
@_onReconnect(update)
@_unsubscribeReconnectHandler = @ide.$scope.$on "project:joined", onReconnectHandler
_unBindFromEditorEvents: () ->
@_unsubscribeReconnectHandler()
_unBindFromSocketEvents: () ->
@ide.socket.removeListener "otUpdateApplied", @_onUpdateAppliedHandler
2014-07-09 15:49:10 -04:00
@ide.socket.removeListener "otUpdateError", @_onErrorHandler
2014-07-08 07:02:26 -04:00
@ide.socket.removeListener "disconnect", @_onDisconnectHandler
leaveAndCleanUp: () ->
@leave (error) =>
@_cleanUp()
join: (callback = (error) ->) ->
@wantToBeJoined = true
@_cancelLeave()
if @connected
return @_joinDoc callback
else
@_joinCallbacks ||= []
@_joinCallbacks.push callback
leave: (callback = (error) ->) ->
@wantToBeJoined = false
@_cancelJoin()
if (@doc? and @doc.hasBufferedOps())
2016-05-27 09:14:08 -04:00
sl_console.log "[leave] Doc has buffered ops, pushing callback for later"
2014-07-08 07:02:26 -04:00
@_leaveCallbacks ||= []
@_leaveCallbacks.push callback
else if !@connected
2016-05-27 09:14:08 -04:00
sl_console.log "[leave] Not connected, returning now"
2014-07-08 07:02:26 -04:00
callback()
else
2016-05-27 09:14:08 -04:00
sl_console.log "[leave] Leaving now"
2014-07-08 07:02:26 -04:00
@_leaveDoc(callback)
flush: () ->
@doc?.flushPendingOps()
2015-12-18 04:55:24 -05:00
chaosMonkey: (line = 0, char = "a") ->
orig = char
copy = null
pos = 0
timer = () =>
unless copy? and copy.length
copy = orig.slice() + ' ' + new Date() + '\n'
line += if Math.random() > 0.1 then 1 else -2
line = 0 if line < 0
pos = 0
char = copy[0]
copy = copy.slice(1)
@ace.session.insert({row: line, column: pos}, char)
pos += 1
@_cm = setTimeout timer, 100 + if Math.random() < 0.1 then 1000 else 0
@_cm = timer()
clearChaosMonkey: () ->
clearTimeout @_cm
MAX_PENDING_OP_SIZE: 64 # pending ops bigger than this are always considered unsaved
2014-07-08 07:02:26 -04:00
pollSavedStatus: () ->
# returns false if doc has ops waiting to be acknowledged or
# sent that haven't changed since the last time we checked.
# Otherwise returns true.
inflightOp = @getInflightOp()
pendingOp = @getPendingOp()
recentAck = @getRecentAck()
pendingOpSize = pendingOp? && @getOpSize(pendingOp)
2014-07-08 07:02:26 -04:00
if !inflightOp? and !pendingOp?
# there's nothing going on, this is ok.
2014-07-08 07:02:26 -04:00
saved = true
sl_console.log "[pollSavedStatus] no inflight or pending ops"
else if inflightOp? and inflightOp == @oldInflightOp
# The same inflight op has been sitting unacked since we
# last checked, this is bad.
2014-07-08 07:02:26 -04:00
saved = false
sl_console.log "[pollSavedStatus] inflight op is same as before"
else if pendingOp? and recentAck && pendingOpSize < @MAX_PENDING_OP_SIZE
# There is an op waiting to go to server but it is small and
# within the flushDelay, this is ok for now.
2014-07-08 07:02:26 -04:00
saved = true
sl_console.log "[pollSavedStatus] pending op (small with recent ack) assume ok", pendingOp, pendingOpSize
else
# In any other situation, assume the document is unsaved.
saved = false
sl_console.log "[pollSavedStatus] assuming not saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
2014-07-08 07:02:26 -04:00
@oldInflightOp = inflightOp
return saved
_cancelLeave: () ->
if @_leaveCallbacks?
delete @_leaveCallbacks
_cancelJoin: () ->
if @_joinCallbacks?
delete @_joinCallbacks
_onUpdateApplied: (update) ->
@ide.pushEvent "received-update",
doc_id: @doc_id
remote_doc_id: update?.doc
wantToBeJoined: @wantToBeJoined
update: update
if window.disconnectOnAck? and Math.random() < window.disconnectOnAck
sl_console.log "Disconnecting on ack", update
window._ide.socket.socket.disconnect()
# Pretend we never received the ack
return
if window.dropAcks? and Math.random() < window.dropAcks
if !update.op? # Only drop our own acks, not collaborator updates
sl_console.log "Simulating a lost ack", update
return
2014-07-08 07:02:26 -04:00
if update?.doc == @doc_id and @doc?
@doc.processUpdateFromServer update
if !@wantToBeJoined
@leave()
_onDisconnect: () ->
sl_console.log '[onDisconnect] disconnecting'
2014-07-08 07:02:26 -04:00
@connected = false
@joined = false
@doc?.updateConnectionState "disconnected"
_onReconnect: () ->
sl_console.log "[onReconnect] reconnected (joined project)"
2014-07-08 07:02:26 -04:00
@ide.pushEvent "reconnected:afterJoinProject"
@connected = true
if @wantToBeJoined or @doc?.hasBufferedOps()
2016-05-27 09:14:08 -04:00
sl_console.log "[onReconnect] Rejoining (wantToBeJoined: #{@wantToBeJoined} OR hasBufferedOps: #{@doc?.hasBufferedOps()})"
2014-07-08 07:02:26 -04:00
@_joinDoc (error) =>
return @_onError(error) if error?
@doc.updateConnectionState "ok"
@doc.flushPendingOps()
@_callJoinCallbacks()
_callJoinCallbacks: () ->
for callback in @_joinCallbacks or []
callback()
delete @_joinCallbacks
_joinDoc: (callback = (error) ->) ->
if @doc?
2017-09-21 10:22:56 -04:00
@ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), { encodeRanges: true }, (error, docLines, version, updates, ranges) =>
2014-07-08 07:02:26 -04:00
return callback(error) if error?
@joined = true
@doc.catchUp( updates )
2017-09-21 10:37:27 -04:00
@_decodeRanges(ranges)
@_catchUpRanges(ranges?.changes, ranges?.comments)
2014-07-08 07:02:26 -04:00
callback()
else
@ide.socket.emit 'joinDoc', @doc_id, { encodeRanges: true }, (error, docLines, version, updates, ranges) =>
2014-07-08 07:02:26 -04:00
return callback(error) if error?
@joined = true
@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
2017-09-21 10:37:27 -04:00
@_decodeRanges(ranges)
@ranges = new RangesTracker(ranges?.changes, ranges?.comments)
2014-07-08 07:02:26 -04:00
@_bindToShareJsDocEvents()
callback()
2017-09-21 08:36:31 -04:00
_decodeRanges: (ranges) ->
2017-09-21 10:37:27 -04:00
decodeFromWebsockets = (text) -> decodeURIComponent(escape(text))
2017-09-21 10:41:07 -04:00
try
for change in ranges.changes or []
change.op.i = decodeFromWebsockets(change.op.i) if change.op.i?
change.op.d = decodeFromWebsockets(change.op.d) if change.op.d?
2017-09-21 10:41:07 -04:00
for comment in ranges.comments or []
comment.op.c = decodeFromWebsockets(comment.op.c) if comment.op.c?
2017-09-21 10:41:07 -04:00
catch err
console.log(err)
2017-09-21 08:36:31 -04:00
2014-07-08 07:02:26 -04:00
_leaveDoc: (callback = (error) ->) ->
2016-05-27 09:14:08 -04:00
sl_console.log '[_leaveDoc] Sending leaveDoc request'
2014-07-08 07:02:26 -04:00
@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
return callback(error) if error?
@joined = false
for callback in @_leaveCallbacks or []
2016-05-27 09:14:08 -04:00
sl_console.log '[_leaveDoc] Calling buffered callback', callback
2014-07-08 07:02:26 -04:00
callback(error)
delete @_leaveCallbacks
callback(error)
_cleanUp: () ->
if Document.openDocs[@doc_id] == @
sl_console.log "[_cleanUp] Removing self (#{@doc_id}) from in openDocs"
delete Document.openDocs[@doc_id]
else
# It's possible that this instance has error, and the doc has been reloaded.
# This creates a new instance in Document.openDoc with the same id. We shouldn't
# clear it because it's not this instance.
sl_console.log "[_cleanUp] New instance of (#{@doc_id}) created. Not removing"
2014-07-08 07:02:26 -04:00
@_unBindFromEditorEvents()
@_unBindFromSocketEvents()
_bindToShareJsDocEvents: () ->
@doc.on "error", (error, meta) => @_onError error, meta
@doc.on "externalUpdate", (update) =>
2014-07-08 07:02:26 -04:00
@ide.pushEvent "externalUpdate",
doc_id: @doc_id
@trigger "externalUpdate", update
@doc.on "remoteop", (args...) =>
2014-07-08 07:02:26 -04:00
@ide.pushEvent "remoteop",
doc_id: @doc_id
@trigger "remoteop", args...
2014-07-08 07:02:26 -04:00
@doc.on "op:sent", (op) =>
@ide.pushEvent "op:sent",
doc_id: @doc_id
op: op
@trigger "op:sent"
@doc.on "op:acknowledged", (op) =>
@ide.pushEvent "op:acknowledged",
doc_id: @doc_id
op: op
2017-09-01 05:32:26 -04:00
@ide.$scope.$emit "ide:opAcknowledged",
doc_id: @doc_id
op: op
2014-07-08 07:02:26 -04:00
@trigger "op:acknowledged"
@doc.on "op:timeout", (op) =>
@ide.pushEvent "op:timeout",
doc_id: @doc_id
op: op
@trigger "op:timeout"
@_onError new Error("op timed out"), {op: op}
2014-07-08 07:02:26 -04:00
@doc.on "flush", (inflightOp, pendingOp, version) =>
@ide.pushEvent "flush",
doc_id: @doc_id,
inflightOp: inflightOp,
pendingOp: pendingOp
v: version
@doc.on "change", (ops, oldSnapshot, msg) =>
@_applyOpsToRanges(ops, oldSnapshot, msg)
2017-01-09 04:49:03 -05:00
@doc.on "flipped_pending_to_inflight", () =>
@trigger "flipped_pending_to_inflight"
2014-07-08 07:02:26 -04:00
_onError: (error, meta = {}) ->
meta.doc_id = @doc_id
sl_console.log "ShareJS error", error, meta
2014-07-08 07:02:26 -04:00
ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
@doc?.clearInflightAndPendingOps()
@trigger "error", error, meta
# The clean up should run after the error is triggered because the error triggers a
# disconnect. If we run the clean up first, we remove our event handlers and miss
# the disconnect event, which means we try to leaveDoc when the connection comes back.
# This could intefere with the new connection of a new instance of this document.
@_cleanUp()
_applyOpsToRanges: (ops = [], oldSnapshot, msg) ->
track_changes_as = null
remote_op = msg?
if msg?.meta?.tc?
old_id_seed = @ranges.getIdSeed()
2017-01-09 04:49:03 -05:00
@ranges.setIdSeed(msg.meta.tc)
if remote_op and msg.meta?.tc
track_changes_as = msg.meta.user_id
else if !remote_op and @track_changes_as?
track_changes_as = @track_changes_as
@ranges.track_changes = track_changes_as?
for op in ops
@ranges.applyOp op, { user_id: track_changes_as }
if old_id_seed?
@ranges.setIdSeed(old_id_seed)
if remote_op
# With remote ops, Ace hasn't been updated when we receive this op,
# so defer updating track changes until it has
setTimeout () => @emit "ranges:dirty"
else
@emit "ranges:dirty"
_catchUpRanges: (changes = [], comments = []) ->
# We've just been given the current server's ranges, but need to apply any local ops we have.
# Reset to the server state then apply our local ops again.
@emit "ranges:clear"
@ranges.changes = changes
@ranges.comments = comments
@ranges.track_changes = @doc.track_changes
for op in @doc.getInflightOp() or []
@ranges.setIdSeed(@doc.track_changes_id_seeds.inflight)
@ranges.applyOp(op, { user_id: @track_changes_as })
for op in @doc.getPendingOp() or []
@ranges.setIdSeed(@doc.track_changes_id_seeds.pending)
@ranges.applyOp(op, { user_id: @track_changes_as })
@emit "ranges:redraw"