Skip to content

Commit

Permalink
Lots of changes, adding a higher level text API for manipulating text…
Browse files Browse the repository at this point in the history
… documents
  • Loading branch information
josephg committed Aug 11, 2011
1 parent 1e226fe commit a33dc2a
Show file tree
Hide file tree
Showing 24 changed files with 1,018 additions and 674 deletions.
1 change: 1 addition & 0 deletions Cakefile
Expand Up @@ -16,6 +16,7 @@ client = [
'client/microevent'
'types/helpers'
'types/text'
'types/text-api'
'types/json'
'client/client'
]
Expand Down
81 changes: 46 additions & 35 deletions src/client/ace.coffee
Expand Up @@ -3,7 +3,7 @@
Range = require("ace/range").Range

# Convert an ace delta into an op understood by share.js
convertDelta = (editorDoc, delta) ->
applyToShareJS = (editorDoc, delta, doc) ->
# Get the start position of the range, in no. of characters
getStartOffsetPosition = (range) ->
# This is quite inefficient - getLines makes a copy of the entire
Expand All @@ -25,61 +25,47 @@ convertDelta = (editorDoc, delta) ->
pos = getStartOffsetPosition(delta.range)

switch delta.action
when 'insertText' then [{i:delta.text, p:pos}]
when 'removeText' then [{d:delta.text, p:pos}]
when 'insertText' then doc.insert delta.text, pos
when 'removeText' then doc.del delta.text.length, pos

when 'insertLines'
text = delta.lines.join('\n') + '\n'
[{i:text, p:pos}]
doc.insert text, pos

when 'removeLines'
text = delta.lines.join('\n') + '\n'
[{d:text, p:pos}]
doc.del text.length, pos

else throw new Error "unknown action: #{delta.action}"

# Apply a share.js op to an ace editor document
applyToDoc = (editorDoc, op) ->
offsetToPos = (offset) ->
# Again, very inefficient.
lines = editorDoc.getAllLines()

row = 0
for line, row in lines
break if offset <= line.length

# +1 for the newline.
offset -= lines[row].length + 1

row:row, column:offset

for c in op
if c.d?
# Delete
range = Range.fromPoints offsetToPos(c.p), offsetToPos(c.p + c.d.length)
editorDoc.remove range
else
# Insert
editorDoc.insert offsetToPos(c.p), c.i

return

window.sharejs.Document::attach_ace = (editor) ->
# Attach an ace editor to the document. The editor's contents are replaced
# with the document's contents unless keepEditorContents is true. (In which case the document's
# contents are nuked and replaced with the editor's).
window.sharejs.Document::attach_ace = (editor, keepEditorContents) ->
throw new Error 'Only text documents can be attached to ace' unless @provides['text']

doc = this
editorDoc = editor.getSession().getDocument()
editorDoc.setNewLineMode 'unix'

check = ->
editorText = editorDoc.getValue()
otText = doc.snapshot
otText = doc.getText()

if editorText != otText
console.error "Text does not match!"
console.error "editor: #{editorText}"
console.error "ot: #{otText}"
# Should probably also replace the editor text with the doc snapshot.

editorDoc.setValue doc.snapshot
if keepEditorContents
doc.del doc.getText().length, 0
doc.insert editorDoc.getValue()
else
editorDoc.setValue doc.asText()

check()

# When we apply ops from sharejs, ace emits edit events. We need to ignore those
Expand All @@ -89,8 +75,7 @@ window.sharejs.Document::attach_ace = (editor) ->
# Listen for edits in ace
editorListener = (change) ->
return if suppress
op = convertDelta editorDoc, change.data
doc.submitOp op
applyToShareJS editorDoc, change.data, doc

check()

Expand All @@ -104,7 +89,33 @@ window.sharejs.Document::attach_ace = (editor) ->

check()

doc.on 'remoteop', docListener

# Horribly inefficient.
offsetToPos = (offset) ->
# Again, very inefficient.
lines = editorDoc.getAllLines()

row = 0
for line, row in lines
break if offset <= line.length

# +1 for the newline.
offset -= lines[row].length + 1

row:row, column:offset

doc.on 'insert', (text, pos) ->
suppress = true
editorDoc.insert offsetToPos(c.p), c.i
suppress = false
check()

doc.on 'delete', (text, pos) ->
suppress = true
range = Range.fromPoints offsetToPos(pos), offsetToPos(pos + text.length)
editorDoc.remove range
suppress = false
check()

doc.detach_ace = ->
doc.removeListener 'remoteop', docListener
Expand Down
120 changes: 63 additions & 57 deletions src/client/client.coffee
Expand Up @@ -19,62 +19,63 @@ else
# Events:
# - remoteop (op)
# - changed (op)
class Document
# stream is a OpStream object.
# name is the documents' docName.
# version is the version of the document _on the server_
constructor: (@connection, @name, @version, @type, snapshot) ->
throw new Error('Handling types without compose() defined is not currently implemented') unless @type.compose?
#
# stream is a OpStream object.
# name is the documents' docName.
# version is the version of the document _on the server_
Document = (connection, @name, @version, @type, snapshot) ->
throw new Error('Handling types without compose() defined is not currently implemented') unless @type.compose?

# Gotta figure out a better way to make this work with closure.
@['snapshot'] = snapshot
# Gotta figure out a cleaner way to make this work with closure.
@setSnapshot = (s) -> @['snapshot'] = @snapshot = s
@setSnapshot(snapshot)

# The op that is currently roundtripping to the server, or null.
@inflightOp = null
@inflightCallbacks = []
# The op that is currently roundtripping to the server, or null.
inflightOp = null
inflightCallbacks = []

# All ops that are waiting for the server to acknowledge @inflightOp
@pendingOp = null
@pendingCallbacks = []
# 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 = {}
# Some recent ops, incase submitOp is called with an old op version number.
serverOps = {}

# Listeners for the document changing
@listeners = []
# Listeners for the document changing
listeners = []

# Internal - do not call directly.
tryFlushPendingOp: =>
if @inflightOp == null && @pendingOp != null
tryFlushPendingOp = =>
if inflightOp == null && pendingOp != null
# Rotate null -> pending -> inflight,
@inflightOp = @pendingOp
@inflightCallbacks = @pendingCallbacks
inflightOp = pendingOp
inflightCallbacks = pendingCallbacks

@pendingOp = null
@pendingCallbacks = []
pendingOp = null
pendingCallbacks = []

@connection.send {'doc':@name, 'op':@inflightOp, 'v':@version}, (response) =>
connection.send {'doc':@name, 'op':inflightOp, 'v':@version}, (response) =>
if response['v'] == null
# Currently, it should be impossible to reach this case.
# This case is currently untested.
callback(null) for callback in @inflightCallbacks
@inflightOp = null
callback(null) for callback in inflightCallbacks
inflightOp = null
# Perhaps the op should be removed from the local document...
# @snapshot = @type.apply @snapshot, type.invert(@inflightOp) if type.invert?
throw new Error(response['error'])

throw new Error('Invalid version from server') unless response['v'] == @version

@serverOps[@version] = @inflightOp
serverOps[@version] = inflightOp
@version++
callback(@inflightOp, null) for callback in @inflightCallbacks
callback(inflightOp, null) for callback in inflightCallbacks

@inflightOp = null
@tryFlushPendingOp()
inflightOp = null
tryFlushPendingOp()

# Internal - do not call directly.
# Called when an op is received from the server.
onOpReceived: (msg) =>
@_onOpReceived = (msg) ->
# msg is {doc:, op:, v:}

# There is a bug in socket.io (produced on firefox 3.6) which causes messages
Expand All @@ -88,7 +89,7 @@ class Document
# p "if: #{i @inflightOp} pending: #{i @pendingOp} doc '#{@snapshot}' op: #{i msg.op}"

op = msg['op']
@serverOps[@version] = op
serverOps[@version] = op

# Transform a server op by a client op, and vice versa.
xf = @type.transformX or (client, server) =>
Expand All @@ -97,62 +98,67 @@ class Document
return [client_, server_]

docOp = op
if @inflightOp != null
[@inflightOp, docOp] = xf @inflightOp, docOp
if @pendingOp != null
[@pendingOp, docOp] = xf @pendingOp, docOp
if inflightOp != null
[inflightOp, docOp] = xf inflightOp, docOp
if pendingOp != null
[pendingOp, docOp] = xf pendingOp, docOp

@['snapshot'] = @type.apply @['snapshot'], docOp
oldSnapshot = @snapshot
@setSnapshot(@type.apply oldSnapshot, docOp)
@version++

@emit 'remoteop', docOp
@emit 'change', docOp
@emit 'remoteop', docOp, oldSnapshot
@emit 'change', docOp, oldSnapshot

# 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, v = @version, callback) ->
@['submitOp'] = @submitOp = (op, v = @version, callback) ->
if typeof v == 'function'
callback = v
v = @version

op = @type.normalize(op) if @type?.normalize?

while v < @version
realOp = @recentOps[v]
# TODO: Add tests for this
realOp = serverOps[v]
throw new Error 'Op version too old' unless realOp
op = @type.transform op, realOp, 'left'
v++

# If this throws an exception, no changes should have been made to the doc
@['snapshot'] = @type.apply @['snapshot'], op
@setSnapshot(@type.apply @snapshot, op)

if @pendingOp != null
@pendingOp = @type.compose(@pendingOp, op)
if pendingOp != null
pendingOp = @type.compose(pendingOp, op)
else
@pendingOp = op
pendingOp = op

@pendingCallbacks.push callback if callback
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
# together and sent together.
setTimeout @tryFlushPendingOp, 0
setTimeout tryFlushPendingOp, 0

# Close a document.
# No unit tests for this so far.
close: (callback) ->
@connection.send {'doc':@name, open:false}, =>
@['close'] = @close = (callback) ->
connection.send {'doc':@name, open:false}, =>
callback() if callback
@emit 'closed'
return

if @type.api
this[k] = v for k, v of @type.api
@_register()
else
@provides = @['provides'] = {}

MicroEvent.mixin Document

# Export the functions for the closure compiler
Document.prototype['submitOp'] = Document.prototype.submitOp
Document.prototype['close'] = Document.prototype.close
this

MicroEvent.mixin Document

# A connection to a sharejs server
class Connection
Expand Down Expand Up @@ -225,7 +231,7 @@ class Connection

if type == 'op'
doc = @docs[docName]
doc.onOpReceived msg if doc
doc._onOpReceived msg if doc

makeDoc: (params) ->
name = params['doc']
Expand Down Expand Up @@ -285,7 +291,7 @@ class Connection
if response.error
callback null, response.error
else
response['snapshot'] = type.initialVersion() unless response['snapshot'] != undefined
response['snapshot'] = type.create() unless response['snapshot'] != undefined
response['type'] = type
callback @makeDoc(response)

Expand Down
10 changes: 4 additions & 6 deletions src/server/model.coffee
Expand Up @@ -55,7 +55,7 @@ module.exports = Model = (db, options) ->
meta ||= {}

newDocData =
snapshot:type.initialVersion()
snapshot:type.create()
type:type.name
meta:meta || {}
v:0
Expand Down Expand Up @@ -89,9 +89,7 @@ module.exports = Model = (db, options) ->
meta = opData.meta || {}
meta.ts = Date.now()

version = docData.v
snapshot = docData.snapshot
type = docData.type
{v:version, snapshot, type} = docData
p "applyOp hasdata v#{opVersion} #{i op} to #{docName}."

submit = ->
Expand All @@ -101,8 +99,8 @@ module.exports = Model = (db, options) ->
callback null, error.message
return

newOpData = {op:op, v:opVersion, meta:meta}
newDocData = {snapshot:snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
newOpData = {op, v:opVersion, meta}
newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}

p "submit #{i newOpData}"
db.append docName, newOpData, newDocData, ->
Expand Down

0 comments on commit a33dc2a

Please sign in to comment.