| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- unless WEB?
- types = require '../types'
- if WEB?
- exports.extendDoc = (name, fn) ->
- Doc::[name] = fn
- # A Doc is a client's view on a sharejs document.
- #
- # Documents are created by calling Connection.open().
- #
- # Documents are event emitters - use doc.on(eventname, fn) to subscribe.
- #
- # Documents get mixed in with their type's API methods. So, you can .insert('foo', 0) into
- # a text document and stuff like that.
- #
- # Events:
- # - remoteop (op)
- # - changed (op)
- # - acknowledge (op)
- # - error
- # - open, closing, closed. 'closing' is not guaranteed to fire before closed.
- class Doc
- # connection is a Connection object.
- # name is the documents' docName.
- # data can optionally contain known document data, and initial open() call arguments:
- # {v[erson], snapshot={...}, type, create=true/false/undefined}
- # callback will be called once the document is first opened.
- constructor: (@connection, @name, openData) ->
- # Any of these can be null / undefined at this stage.
- openData ||= {}
- @version = openData.v
- @snapshot = openData.snaphot
- @_setType openData.type if openData.type
- @state = 'closed'
- @autoOpen = false
- # Has the document already been created?
- @_create = openData.create
- # The op that is currently roundtripping to the server, or null.
- #
- # When the connection reconnects, the inflight op is resubmitted.
- @inflightOp = null
- @inflightCallbacks = []
- # The auth ids which the client has previously used to attempt to send inflightOp. This is
- # usually empty.
- @inflightSubmittedIds = []
- # All ops that are waiting for the server to acknowledge @inflightOp
- @pendingOp = null
- @pendingCallbacks = []
- # Some recent ops, incase submitOp is called with an old op version number.
- @serverOps = {}
- # Transform a server op by a client op, and vice versa.
- _xf: (client, server) ->
- if @type.transformX
- @type.transformX(client, server)
- else
- client_ = @type.transform client, server, 'left'
- server_ = @type.transform server, client, 'right'
- return [client_, server_]
-
- _otApply: (docOp, isRemote) ->
- oldSnapshot = @snapshot
- @snapshot = @type.apply(@snapshot, docOp)
- # Its important that these event handlers are called with oldSnapshot.
- # The reason is that the OT type APIs might need to access the snapshots to
- # determine information about the received op.
- @emit 'change', docOp, oldSnapshot
- @emit 'remoteop', docOp, oldSnapshot if isRemote
-
- _connectionStateChanged: (state, data) ->
- switch state
- when 'disconnected'
- @state = 'closed'
- # This is used by the server to make sure that when an op is resubmitted it
- # doesn't end up getting applied twice.
- @inflightSubmittedIds.push @connection.id if @inflightOp
- @emit 'closed'
- when 'ok' # Might be able to do this when we're connecting... that would save a roundtrip.
- @open() if @autoOpen
- when 'stopped'
- @_openCallback? data
- @emit state, data
- _setType: (type) ->
- if typeof type is 'string'
- type = types[type]
- throw new Error 'Support for types without compose() is not implemented' unless type and type.compose
- @type = type
- if type.api
- this[k] = v for k, v of type.api
- @_register?()
- else
- @provides = {}
- _onMessage: (msg) ->
- #console.warn 's->c', msg
- if msg.open == true
- # The document has been successfully opened.
- @state = 'open'
- @_create = false # Don't try and create the document again next time open() is called.
- unless @created?
- @created = !!msg.create
- @_setType msg.type if msg.type
- if msg.create
- @created = true
- @snapshot = @type.create()
- else
- @created = false unless @created is true
- @snapshot = msg.snapshot if msg.snapshot isnt undefined
- @version = msg.v if msg.v?
- # Resend any previously queued operation.
- if @inflightOp
- response =
- doc: @name
- op: @inflightOp
- v: @version
- response.dupIfSource = @inflightSubmittedIds if @inflightSubmittedIds.length
- @connection.send response
- else
- @flush()
- @emit 'open'
-
- @_openCallback? null
-
- else if msg.open == false
- # The document has either been closed, or an open request has failed.
- if msg.error
- # An error occurred opening the document.
- console?.error "Could not open document: #{msg.error}"
- @emit 'error', msg.error
- @_openCallback? msg.error
- @state = 'closed'
- @emit 'closed'
- @_closeCallback?()
- @_closeCallback = null
- else if msg.op is null and error is 'Op already submitted'
- # We've tried to resend an op to the server, which has already been received successfully. Do nothing.
- # The op will be confirmed normally when we get the op itself was echoed back from the server
- # (handled below).
- else if (msg.op is undefined and msg.v isnt undefined) or (msg.op and msg.meta.source in @inflightSubmittedIds)
- # Our inflight op has been acknowledged.
- oldInflightOp = @inflightOp
- @inflightOp = null
- @inflightSubmittedIds.length = 0
- error = msg.error
- if error
- # The server has rejected an op from the client for some reason.
- # We'll send the error message to the user and roll back the change.
- #
- # If the server isn't going to allow edits anyway, we should probably
- # figure out some way to flag that (readonly:true in the open request?)
- if @type.invert
- undo = @type.invert oldInflightOp
- # Now we have to transform the undo operation by any server ops & pending ops
- if @pendingOp
- [@pendingOp, undo] = @_xf @pendingOp, undo
- # ... and apply it locally, reverting the changes.
- #
- # This call will also call @emit 'remoteop'. I'm still not 100% sure about this
- # functionality, because its really a local op. Basically, the problem is that
- # if the client's op is rejected by the server, the editor window should update
- # to reflect the undo.
- @_otApply undo, true
- else
- @emit 'error', "Op apply failed (#{error}) and the op could not be reverted"
- callback error for callback in @inflightCallbacks
- else
- # The op applied successfully.
- throw new Error('Invalid version from server') unless msg.v == @version
- @serverOps[@version] = oldInflightOp
- @version++
- @emit 'acknowledge', oldInflightOp
- callback null, oldInflightOp for callback in @inflightCallbacks
- # Send the next op.
- @flush()
- else if msg.op
- # We got a new op from the server.
- # msg is {doc:, op:, v:}
- # There is a bug in socket.io (produced on firefox 3.6) which causes messages
- # to be duplicated sometimes.
- # We'll just silently drop subsequent messages.
- return if msg.v < @version
- return @emit 'error', "Expected docName '#{@name}' but got #{msg.doc}" unless msg.doc == @name
- return @emit 'error', "Expected version #{@version} but got #{msg.v}" unless msg.v == @version
- # p "if: #{i @inflightOp} pending: #{i @pendingOp} doc '#{@snapshot}' op: #{i msg.op}"
- op = msg.op
- @serverOps[@version] = op
- docOp = op
- if @inflightOp != null
- [@inflightOp, docOp] = @_xf @inflightOp, docOp
- if @pendingOp != null
- [@pendingOp, docOp] = @_xf @pendingOp, docOp
-
- @version++
- # Finally, apply the op to @snapshot and trigger any event listeners
- @_otApply docOp, true
- else if msg.meta
- {path, value} = msg.meta
- switch path?[0]
- when 'shout'
- return @emit 'shout', value
- else
- console?.warn 'Unhandled meta op:', msg
- else
- console?.warn 'Unhandled document message:', msg
- # Send ops to the server, if appropriate.
- #
- # Only one op can be in-flight at a time, so if an op is already on its way then
- # this method does nothing.
- flush: =>
- return unless @connection.state == 'ok' and @inflightOp == null and @pendingOp != null
- # Rotate null -> pending -> inflight
- @inflightOp = @pendingOp
- @inflightCallbacks = @pendingCallbacks
- @pendingOp = null
- @pendingCallbacks = []
- @connection.send {doc:@name, op:@inflightOp, v:@version}
- # Submit an op to the server. The op maybe held for a little while before being sent, as only one
- # op can be inflight at any time.
- submitOp: (op, callback) ->
- op = @type.normalize(op) if @type.normalize?
- # If this throws an exception, no changes should have been made to the doc
- @snapshot = @type.apply @snapshot, op
- if @pendingOp != null
- @pendingOp = @type.compose(@pendingOp, op)
- else
- @pendingOp = op
- @pendingCallbacks.push callback if callback
- @emit 'change', op
- # A timeout is used so if the user sends multiple ops at the same time, they'll be composed
- # & sent together.
- setTimeout @flush, 0
-
- shout: (msg) =>
- # Meta ops don't have to queue, they can go direct. Good/bad idea?
- @connection.send {doc:@name, meta: { path: ['shout'], value: msg } }
-
- # Open a document. The document starts closed.
- open: (callback) ->
- @autoOpen = true
- return unless @state is 'closed'
- message =
- doc: @name
- open: true
- message.snapshot = null if @snapshot is undefined
- message.type = @type.name if @type
- message.v = @version if @version?
- message.create = true if @_create
- @connection.send message
- @state = 'opening'
- @_openCallback = (error) =>
- @_openCallback = null
- callback? error
- # Close a document.
- close: (callback) ->
- @autoOpen = false
- return callback?() if @state is 'closed'
- @connection.send {doc:@name, open:false}
- # Should this happen immediately or when we get open:false back from the server?
- @state = 'closed'
- @emit 'closing'
- @_closeCallback = callback
-
- # Make documents event emitters
- unless WEB?
- MicroEvent = require './microevent'
- MicroEvent.mixin Doc
- exports.Doc = Doc
|