This commit is contained in:
James Allen 2017-03-01 16:49:46 +00:00
parent 9cfe651930
commit d56bb55953
4 changed files with 49 additions and 131 deletions

View file

@ -1,5 +1,5 @@
load = () -> load = (EventEmitter) ->
class RangesTracker class RangesTracker extends EventEmitter
# The purpose of this class is to track a set of inserts and deletes to a document, like # The purpose of this class is to track a set of inserts and deletes to a document, like
# track changes in Word. We store these as a set of ShareJs style ranges: # track changes in Word. We store these as a set of ShareJs style ranges:
# {i: "foo", p: 42} # Insert 'foo' at offset 42 # {i: "foo", p: 42} # Insert 'foo' at offset 42
@ -36,7 +36,6 @@ load = () ->
# middle of a previous insert by the first user, the original insert will be split into two. # middle of a previous insert by the first user, the original insert will be split into two.
constructor: (@changes = [], @comments = []) -> constructor: (@changes = [], @comments = []) ->
@setIdSeed(RangesTracker.generateIdSeed()) @setIdSeed(RangesTracker.generateIdSeed())
@resetDirtyState()
getIdSeed: () -> getIdSeed: () ->
return @id_seed return @id_seed
@ -76,7 +75,7 @@ load = () ->
comment = @getComment(comment_id) comment = @getComment(comment_id)
return if !comment? return if !comment?
@comments = @comments.filter (c) -> c.id != comment_id @comments = @comments.filter (c) -> c.id != comment_id
@_markAsDirty comment, "comment", "removed" @emit "comment:removed", comment
getChange: (change_id) -> getChange: (change_id) ->
change = null change = null
@ -104,11 +103,7 @@ load = () ->
@addComment(op, metadata) @addComment(op, metadata)
else else
throw new Error("unknown op type") throw new Error("unknown op type")
applyOps: (ops, metadata = {}) ->
for op in ops
@applyOp(op, metadata)
addComment: (op, metadata) -> addComment: (op, metadata) ->
# TODO: Don't allow overlapping comments? # TODO: Don't allow overlapping comments?
@comments.push comment = { @comments.push comment = {
@ -119,18 +114,18 @@ load = () ->
t: op.t t: op.t
metadata metadata
} }
@_markAsDirty comment, "comment", "added" @emit "comment:added", comment
return comment return comment
applyInsertToComments: (op) -> applyInsertToComments: (op) ->
for comment in @comments for comment in @comments
if op.p <= comment.op.p if op.p <= comment.op.p
comment.op.p += op.i.length comment.op.p += op.i.length
@_markAsDirty comment, "comment", "moved" @emit "comment:moved", comment
else if op.p < comment.op.p + comment.op.c.length else if op.p < comment.op.p + comment.op.c.length
offset = op.p - comment.op.p offset = op.p - comment.op.p
comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...] comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
@_markAsDirty comment, "comment", "moved" @emit "comment:moved", comment
applyDeleteToComments: (op) -> applyDeleteToComments: (op) ->
op_start = op.p op_start = op.p
@ -143,7 +138,7 @@ load = () ->
if op_end <= comment_start if op_end <= comment_start
# delete is fully before comment # delete is fully before comment
comment.op.p -= op_length comment.op.p -= op_length
@_markAsDirty comment, "comment", "moved" @emit "comment:moved", comment
else if op_start >= comment_end else if op_start >= comment_end
# delete is fully after comment, nothing to do # delete is fully after comment, nothing to do
else else
@ -166,13 +161,12 @@ load = () ->
comment.op.p = Math.min(comment_start, op_start) comment.op.p = Math.min(comment_start, op_start)
comment.op.c = remaining_before + remaining_after comment.op.c = remaining_before + remaining_after
@_markAsDirty comment, "comment", "moved" @emit "comment:moved", comment
applyInsertToChanges: (op, metadata) -> applyInsertToChanges: (op, metadata) ->
op_start = op.p op_start = op.p
op_length = op.i.length op_length = op.i.length
op_end = op.p + op_length op_end = op.p + op_length
undoing = !!op.u
already_merged = false already_merged = false
@ -190,9 +184,8 @@ load = () ->
change.op.p += op_length change.op.p += op_length
moved_changes.push change moved_changes.push change
else if op_start == change_start else if op_start == change_start
# If we are undoing, then we want to cancel any existing delete ranges if we can. # If the insert matches the start of the delete, just remove it from the delete instead
# Check if the insert matches the start of the delete, and just remove it from the delete instead if so. if change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
if undoing and change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
change.op.d = change.op.d.slice(op.i.length) change.op.d = change.op.d.slice(op.i.length)
change.op.p += op.i.length change.op.p += op.i.length
if change.op.d == "" if change.op.d == ""
@ -210,15 +203,15 @@ load = () ->
# Only merge inserts if they are from the same user # Only merge inserts if they are from the same user
is_same_user = metadata.user_id == change.metadata.user_id is_same_user = metadata.user_id == change.metadata.user_id
# If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also # If this is an insert op at the end of an existing insert with a delete following, and it cancels out the following
# an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete. # delete then we shouldn't append it to this insert, but instead only cancel the following delete.
# E.g. # E.g.
# foo|<--- about to insert 'b' here # foo|<--- about to insert 'b' here
# inserted 'foo' --^ ^-- deleted 'bar' # inserted 'foo' --^ ^-- deleted 'bar'
# should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), . # should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
next_change = @changes[i+1] next_change = @changes[i+1]
is_op_adjacent_to_next_delete = next_change? and next_change.op.d? and op.p == change_end and next_change.op.p == op.p is_op_adjacent_to_next_delete = next_change? and next_change.op.d? and op.p == change_end and next_change.op.p == op.p
will_op_cancel_next_delete = undoing and is_op_adjacent_to_next_delete and next_change.op.d.slice(0, op.i.length) == op.i will_op_cancel_next_delete = is_op_adjacent_to_next_delete and next_change.op.d.slice(0, op.i.length) == op.i
# If there is a delete at the start of the insert, and we're inserting # If there is a delete at the start of the insert, and we're inserting
# at the start, we SHOULDN'T merge since the delete acts as a partition. # at the start, we SHOULDN'T merge since the delete acts as a partition.
@ -288,8 +281,8 @@ load = () ->
for change in remove_changes for change in remove_changes
@_removeChange change @_removeChange change
for change in moved_changes if moved_changes.length > 0
@_markAsDirty change, "change", "moved" @emit "changes:moved", moved_changes
applyDeleteToChanges: (op, metadata) -> applyDeleteToChanges: (op, metadata) ->
op_start = op.p op_start = op.p
@ -413,8 +406,8 @@ load = () ->
@_removeChange change @_removeChange change
moved_changes = moved_changes.filter (c) -> c != change moved_changes = moved_changes.filter (c) -> c != change
for change in moved_changes if moved_changes.length > 0
@_markAsDirty change, "change", "moved" @emit "changes:moved", moved_changes
_addOp: (op, metadata) -> _addOp: (op, metadata) ->
change = { change = {
@ -434,11 +427,17 @@ load = () ->
else else
return -1 return -1
@_markAsDirty(change, "change", "added") if op.d?
@emit "delete:added", change
else if op.i?
@emit "insert:added", change
_removeChange: (change) -> _removeChange: (change) ->
@changes = @changes.filter (c) -> c.id != change.id @changes = @changes.filter (c) -> c.id != change.id
@_markAsDirty change, "change", "removed" if change.op.d?
@emit "delete:removed", change
else if change.op.i?
@emit "insert:removed", change
_applyOpModifications: (content, op_modifications) -> _applyOpModifications: (content, op_modifications) ->
# Put in descending position order, with deleting first if at the same offset # Put in descending position order, with deleting first if at the same offset
@ -487,32 +486,13 @@ load = () ->
previous_change = change previous_change = change
return { moved_changes, remove_changes } return { moved_changes, remove_changes }
resetDirtyState: () ->
@_dirtyState = {
comment: {
moved: {}
removed: {}
added: {}
}
change: {
moved: {}
removed: {}
added: {}
}
}
getDirtyState: () ->
return @_dirtyState
_markAsDirty: (object, type, action) ->
@_dirtyState[type][action][object.id] = object
_clone: (object) -> _clone: (object) ->
clone = {} clone = {}
(clone[k] = v for k,v of object) (clone[k] = v for k,v of object)
return clone return clone
if define? if define?
define [], load define ["utils/EventEmitter"], load
else else
module.exports = load() EventEmitter = require("events").EventEmitter
module.exports = load(EventEmitter)

View file

@ -11,20 +11,14 @@ text.api =
# Get the text contents of a document # Get the text contents of a document
getText: -> @snapshot getText: -> @snapshot
insert: (pos, text, fromUndo, callback) -> insert: (pos, text, callback) ->
op = {p:pos, i:text} op = [{p:pos, i:text}]
if fromUndo
op.u = true
op = [op]
@submitOp op, callback @submitOp op, callback
op op
del: (pos, length, fromUndo, callback) -> del: (pos, length, callback) ->
op = {p:pos, d:@snapshot[pos...(pos + length)]} op = [{p:pos, d:@snapshot[pos...(pos + length)]}]
if fromUndo
op.u = true
op = [op]
@submitOp op, callback @submitOp op, callback
op op
@ -34,5 +28,5 @@ text.api =
for component in op for component in op
if component.i != undefined if component.i != undefined
@emit 'insert', component.p, component.i @emit 'insert', component.p, component.i
else if component.d != undefined else
@emit 'delete', component.p, component.d @emit 'delete', component.p, component.d

View file

@ -56,13 +56,6 @@ text.apply = (snapshot, op) ->
throw new Error "Unknown op type" throw new Error "Unknown op type"
snapshot snapshot
cloneAndModify = (op, modifications) ->
newOp = {}
for k,v of op
newOp[k] = v
for k,v of modifications
newOp[k] = v
return newOp
# Exported for use by the random op generator. # Exported for use by the random op generator.
# #
@ -76,10 +69,10 @@ text._append = append = (newOp, c) ->
last = newOp[newOp.length - 1] last = newOp[newOp.length - 1]
# Compose the insert into the previous insert if possible # Compose the insert into the previous insert if possible
if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length) and last.u == c.u if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length)
newOp[newOp.length - 1] = cloneAndModify(last, {i:strInject(last.i, c.p - last.p, c.i)}) newOp[newOp.length - 1] = {i:strInject(last.i, c.p - last.p, c.i), p:last.p}
else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length) and last.u == c.u else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length)
newOp[newOp.length - 1] = cloneAndModify(last, {d:strInject(c.d, last.p - c.p, last.d), p: c.p}) newOp[newOp.length - 1] = {d:strInject(c.d, last.p - c.p, last.d), p:c.p}
else else
newOp.push c newOp.push c
@ -157,25 +150,25 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
checkValidOp [otherC] checkValidOp [otherC]
if c.i? if c.i?
append dest, cloneAndModify(c, {p:transformPosition(c.p, otherC, side == 'right')}) append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')}
else if c.d? # Delete else if c.d? # Delete
if otherC.i? # delete vs insert if otherC.i? # delete vs insert
s = c.d s = c.d
if c.p < otherC.p if c.p < otherC.p
append dest, cloneAndModify(c, {d:s[...otherC.p - c.p]}) append dest, {d:s[...otherC.p - c.p], p:c.p}
s = s[(otherC.p - c.p)..] s = s[(otherC.p - c.p)..]
if s != '' if s != ''
append dest, cloneAndModify(c, {d:s, p:c.p + otherC.i.length}) append dest, {d:s, p:c.p + otherC.i.length}
else if otherC.d? # Delete vs delete else if otherC.d? # Delete vs delete
if c.p >= otherC.p + otherC.d.length if c.p >= otherC.p + otherC.d.length
append dest, cloneAndModify(c, {p:c.p - otherC.d.length}) append dest, {d:c.d, p:c.p - otherC.d.length}
else if c.p + c.d.length <= otherC.p else if c.p + c.d.length <= otherC.p
append dest, c append dest, c
else else
# They overlap somewhere. # They overlap somewhere.
newC = cloneAndModify(c, {d:''}) newC = {d:'', p:c.p}
if c.p < otherC.p if c.p < otherC.p
newC.d = c.d[...(otherC.p - c.p)] newC.d = c.d[...(otherC.p - c.p)]
if c.p + c.d.length > otherC.p + otherC.d.length if c.p + c.d.length > otherC.p + otherC.d.length
@ -205,18 +198,18 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
if c.p < otherC.p < c.p + c.c.length if c.p < otherC.p < c.p + c.c.length
offset = otherC.p - c.p offset = otherC.p - c.p
new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...]) new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...])
append dest, cloneAndModify(c, {c:new_c}) append dest, {c:new_c, p:c.p, t: c.t}
else else
append dest, cloneAndModify(c, {p:transformPosition(c.p, otherC, true)}) append dest, {c:c.c, p:transformPosition(c.p, otherC, true), t: c.t}
else if otherC.d? else if otherC.d?
if c.p >= otherC.p + otherC.d.length if c.p >= otherC.p + otherC.d.length
append dest, cloneAndModify(c, {p:c.p - otherC.d.length}) append dest, {c:c.c, p:c.p - otherC.d.length, t: c.t}
else if c.p + c.c.length <= otherC.p else if c.p + c.c.length <= otherC.p
append dest, c append dest, c
else # Delete overlaps comment else # Delete overlaps comment
# They overlap somewhere. # They overlap somewhere.
newC = cloneAndModify(c, {c:''}) newC = {c:'', p:c.p, t: c.t}
if c.p < otherC.p if c.p < otherC.p
newC.c = c.c[...(otherC.p - c.p)] newC.c = c.c[...(otherC.p - c.p)]
if c.p + c.c.length > otherC.p + otherC.d.length if c.p + c.c.length > otherC.p + otherC.d.length

View file

@ -27,11 +27,6 @@ describe "ShareJS text type", ->
dest = [] dest = []
text._tc(dest, { i: "foo", p: 3 }, { i: "bar", p: 3 }, 'left') text._tc(dest, { i: "foo", p: 3 }, { i: "bar", p: 3 }, 'left')
dest.should.deep.equal [{ i: "foo", p: 3 }] dest.should.deep.equal [{ i: "foo", p: 3 }]
it "should preserve the undo flag", ->
dest = []
text._tc(dest, { i: "foo", p: 9, u: true }, { i: "bar", p: 3 })
dest.should.deep.equal [{ i: "foo", p: 12, u: true }]
describe "insert / delete", -> describe "insert / delete", ->
it "with a delete before", -> it "with a delete before", ->
@ -51,13 +46,9 @@ describe "ShareJS text type", ->
it "with a delete at the same place with side == 'left'", -> it "with a delete at the same place with side == 'left'", ->
dest = [] dest = []
text._tc(dest, { i: "foo", p: 3 }, { d: "bar", p: 3 }, 'left') text._tc(dest, { i: "foo", p: 3 }, { d: "bar", p: 3 }, 'left')
dest.should.deep.equal [{ i: "foo", p: 3 }] dest.should.deep.equal [{ i: "foo", p: 3 }]
it "should preserve the undo flag", ->
dest = []
text._tc(dest, { i: "foo", p: 9, u: true }, { d: "bar", p: 3 })
dest.should.deep.equal [{ i: "foo", p: 6, u: true }]
describe "delete / insert", -> describe "delete / insert", ->
it "with an insert before", -> it "with an insert before", ->
@ -84,11 +75,7 @@ describe "ShareJS text type", ->
dest = [] dest = []
text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 4 }) text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 4 })
dest.should.deep.equal [{ d: "f", p: 3 }, { d: "oo", p: 6 }] dest.should.deep.equal [{ d: "f", p: 3 }, { d: "oo", p: 6 }]
it "should preserve the undo flag", ->
dest = []
text._tc(dest, { d: "foo", p: 9, u: true }, { i: "bar", p: 3 })
dest.should.deep.equal [{ d: "foo", p: 12, u: true }]
describe "delete / delete", -> describe "delete / delete", ->
it "with a delete before", -> it "with a delete before", ->
@ -125,11 +112,6 @@ describe "ShareJS text type", ->
dest = [] dest = []
text._tc(dest, { d: "foo", p: 6 }, { d: "abcfoo123", p: 3 }) text._tc(dest, { d: "foo", p: 6 }, { d: "abcfoo123", p: 3 })
dest.should.deep.equal [] dest.should.deep.equal []
it "should preserve the undo flag", ->
dest = []
text._tc(dest, { d: "foo", p: 9, u: true }, { d: "bar", p: 3 })
dest.should.deep.equal [{ d: "foo", p: 6, u: true }]
describe "comment / insert", -> describe "comment / insert", ->
it "with an insert before", -> it "with an insert before", ->
@ -228,37 +210,6 @@ describe "ShareJS text type", ->
text.apply("foo123bar", [{ c: "456", p: 3 }]) text.apply("foo123bar", [{ c: "456", p: 3 }])
).should.throw(Error) ).should.throw(Error)
describe "_append", ->
it "should combine adjacent inserts", ->
dest = [{ i: "foo", p: 3 }]
text._append dest, { i: "bar", p: 6 }
dest.should.deep.equal [{ i: "foobar", p: 3 }]
it "should combine adjacent undo inserts", ->
dest = [{ i: "foo", p: 3, u: true }]
text._append dest, { i: "bar", p: 6, u: true }
dest.should.deep.equal [{ i: "foobar", p: 3, u: true }]
it "should not combine an undo and a normal insert", ->
dest = [{ i: "foo", p: 3, u: true }]
text._append dest, { i: "bar", p: 6 }
dest.should.deep.equal [{ i: "foo", p: 3, u: true }, { i: "bar", p: 6 }]
it "should combine adjacent deletes", ->
dest = [{ d: "bar", p: 6 }]
text._append dest, { d: "foobaz", p: 3 }
dest.should.deep.equal [{ d: "foobarbaz", p: 3 }]
it "should combine adjacent undo deletes", ->
dest = [{ d: "foo", p: 3, u: true }]
text._append dest, { d: "bar", p: 3, u: true }
dest.should.deep.equal [{ d: "foobar", p: 3, u: true }]
it "should not combine an undo and a normal insert", ->
dest = [{ d: "foo", p: 3, u: true }]
text._append dest, { d: "bar", p: 3 }
dest.should.deep.equal [{ d: "foo", p: 3, u: true }, { d: "bar", p: 3 }]
describe "applying ops and comments in different orders", -> describe "applying ops and comments in different orders", ->
it "should not matter which op or comment is applied first", -> it "should not matter which op or comment is applied first", ->
transform = (op1, op2, side) -> transform = (op1, op2, side) ->