diff --git a/.gitignore b/.gitignore index eda715481b..6b56fc171d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ bin/ .DS_Store - +lib/jetsync.js diff --git a/demo/index.haml b/demo/index.haml index 02bb65384d..1f99ab5265 100644 --- a/demo/index.haml +++ b/demo/index.haml @@ -35,6 +35,7 @@ %script{:type => 'text/javascript', :src => 'lib/underscore.js'} %script{:type => 'text/javascript', :src => 'lib/eventemitter2.js'} %script{:type => 'text/javascript', :src => 'lib/rangy/rangy-core.js'} + %script{:type => 'text/javascript', :src => 'lib/jetsync.js'} %script{:type => 'text/javascript', :src => 'src/selection.js'} %script{:type => 'text/javascript', :src => 'src/document.js'} %script{:type => 'text/javascript', :src => 'src/editor.js'} diff --git a/lib/jetsync.coffee b/lib/jetsync.coffee new file mode 100644 index 0000000000..fdc075f42e --- /dev/null +++ b/lib/jetsync.coffee @@ -0,0 +1,389 @@ +# This library contains the synchronization code for clients' edits. + +class JetDeltaItem + constructor: (@attributes = {}) + + addAttributes: (attributes) -> + addedAttributes = {} + for key, value of attributes + if @attributes[key] == undefined + addedAttributes[key] = value + return addedAttributes + + attributesMatch: (other) -> + otherAttributes = other.attributes || {} + for attribute, value of @attributes + if otherAttributes[attribute] != value + return false + for attribute, value of otherAttributes + if @attributes[attribute] != value + return false + return true + + composeAttributes: (attributes) -> + return if !attributes? + for key, value of attributes + @attributes[key] = if value? then value else undefined + + toString: -> + attr_str = "" + for key,value of @attributes + attr_str += "#{key}: #{value}, " + return "{#{attr_str}}" + + @copyAttributes: (deltaItem) -> + attributes = {} + for attribute, value of deltaItem.attributes when value? + attributes[attribute] = value + return attributes + + +# Used to represent retains in the delta. [inclusive, exclusive) +class JetRetain extends JetDeltaItem + constructor: (@start, @end, @attributes = {}) -> + console.assert(@start >= 0, "JetRetain start cannot be negative!", @start) + console.assert(@end >= @start, "JetRetain end must be >= start!", @start, @end) + + @copy: (subject) -> + console.assert(JetRetain.isRetain(subject), "Copy called on non-retain", subject) + attributes = JetDeltaItem.copyAttributes(subject) + return new JetRetain(subject.start, subject.end, attributes) + + isEqual: (other)-> + if !other + return false + return @start == other.start and @end == other.end and + this.attributesMatch(other) + + toString: -> + return "{{#{@start} - #{@end}), #{super()}}" + + @isRetain: (r) -> + return r? && typeof r.start == "number" && typeof r.end == "number" + + +class JetInsert extends JetDeltaItem + constructor: (@text, @attributes = {}) -> + # console.assert(@text.length > 0) + @length = @text.length + + @copy: (subject) -> + attributes = JetDeltaItem.copyAttributes(subject) + return new JetInsert(subject.text, attributes) + + isEqual: (other) -> + if !other + return false + return @text == other.text and this.attributesMatch(other) + + toString: -> + return "{#{@text}, #{super()}}" + + @isInsert: (i) -> + return i? && typeof i.text == "string" && typeof i.length == "number" + + +class JetDelta + constructor: (@startLength, @endLength, @deltas, skipNormalizing = false) -> + if !skipNormalizing + this.normalizeChanges() + length = 0 + for delta in @deltas + if JetDelta.isRetain(delta) + length += delta.end - delta.start + else + length += delta.length + console.assert(length == @endLength, "Given end length is incorrect", this) + + isIdentity: -> + if @startLength == @endLength + if @deltas.length == 0 + return true + if @deltas.length == 1 && JetRetain.isRetain(@deltas[0]) && @deltas[0].start == 0 && @deltas[0].end == @endLength + return true + return false + + normalizeChanges: -> + return if @deltas.length == 0 + for i in [0..@deltas.length - 1] + switch typeof @deltas[i] + when 'string' then @deltas[i] = new JetInsert(@deltas[i]) + when 'number' then @deltas[i] = new JetRetain(@deltas[i], @deltas[i] + 1) + @deltas[i].attributes = {} unless @deltas[i].attributes? + + compact: -> + this.normalizeChanges() + compacted = [] + for delta in @deltas + if compacted.length == 0 + compacted.push(delta) unless JetRetain.isRetain(delta) && delta.start == delta.end + else + if JetRetain.isRetain(delta) && delta.start == delta.end + continue + last = compacted[compacted.length - 1] + if JetDelta.isInsert(last) && JetDelta.isInsert(delta) && last.attributesMatch(delta) + # If two neighboring inserts, combine + last.text = last.text + delta.text + last.length = last.text.length + else if JetRetain.isRetain(last) && JetRetain.isRetain(delta) && last.end == delta.start && last.attributesMatch(delta) + # If two neighboring ranges first's end + 1 == second's start, combine + last.end = delta.end + else + # Cannot coalesce with previous + compacted.push(delta) + @deltas = compacted + + getDeltasAt: (range) -> + changes = [] + index = 0 + if range.start == range.end + return [] + if typeof range == 'number' + range = new JetRetain(range, range + 1) + else + range = JetRetain.copy(range) + for delta in @deltas + console.assert(JetDelta.isRetain(delta) || JetDelta.isInsert(delta), "Invalid change in delta", this) + length = if JetDelta.isInsert(delta) then delta.length else delta.end - delta.start + if index <= range.start && range.start < index + length + start = Math.max(index, range.start) + end = Math.min(index + length, range.end) + if JetDelta.isInsert(delta) + changes.push(new JetInsert(delta.text.substring(start - index, end - + index), delta.attributes)) + else + changes.push(new JetRetain(start - index + delta.start, end - index + + delta.start, delta.attributes)) + range.start = end + index += length + return changes + + @copy: (subject) -> + changes = [] + for delta in subject.deltas + if JetDelta.isRetain(delta) + changes.push(JetRetain.copy(delta)) + else + changes.push(JetInsert.copy(delta)) + return new JetDelta(subject.startLength, subject.endLength, changes, true) + + @getInitial: (contents) -> + return new JetDelta(0, contents.length, [new JetInsert(contents)]) + + @getIdentity: (length) -> + delta = new JetDelta(length, length, [new JetRetain(0, length)]) + return delta + + @isDelta: (delta) -> + if (delta? && typeof delta == "object" && typeof delta.startLength == "number" && + typeof delta.endLength == "number" && typeof delta.deltas == "object") + for delta in delta.deltas + if !JetDelta.isRetain(delta) && !JetDelta.isInsert(delta) + return false + return true + return false + + @makeDelta: (obj, skipNormalizing = false) -> + return new JetDelta(obj.startLength, obj.endLength, obj.deltas, skipNormalizing) + + + isEqual: (other) -> + # TODO: Check for existence of properties before checking their values + if @startLength != other.startLength or @endLength != other.endLength + console.warn("Start/end Len mismatch!") + return false + + if @deltas.length != other.deltas.length + console.warn("Array len mismatch: " + @deltas.join("/") + other.deltas.join(", ")) + return false + + if @deltas.length == 0 + return true + + for i in [0..@deltas.length - 1] + if typeof(@deltas[i]) != "object" + console.warn("Delta is not an object: #{@delta[i]}") + return false + + if typeof(other.deltas[i]) != "object" + console.warn("Other delta is not an object: #{@delta[i]}") + return false + + if !@deltas[i].isEqual(other.deltas[i]) + console.warn("Mismatch!" + other.deltas.join(", ")) + return false + return true + + @isInsert: (change) -> + return JetInsert.isInsert(change) + + @isRetain: (change) -> + return JetRetain.isRetain(change) || typeof(change) == "number" + + toString: -> + return "{(#{@startLength}->#{@endLength})[#{@deltas.join(', ')}]}" + +JetSync = + # Inserts in deltaB are given priority. Retains in deltaB are indexes into A, + # and we take whatever is there (insert or retain). + compose: (deltaA, deltaB) -> + console.assert(JetDelta.isDelta(deltaA), "Compose called when deltaA is not a JetDelta, type: " + typeof deltaA) + console.assert(JetDelta.isDelta(deltaB), "Compose called when deltaB is not a JetDelta, type: " + typeof deltaB) + console.assert(deltaA.endLength == deltaB.startLength, "startLength #{deltaB.startLength} / endlength #{deltaA.endLength} mismatch") + + deltaA = JetDelta.copy(deltaA) + deltaB = JetDelta.copy(deltaB) + deltaA.normalizeChanges() + deltaB.normalizeChanges() + + composed = [] + for elem in deltaB.deltas + elem = new JetInsert(elem) if typeof elem == 'string' + if JetDelta.isInsert(elem) + composed.push(elem) + else if JetDelta.isRetain(elem) + deltasInRange = deltaA.getDeltasAt(elem) + for delta in deltasInRange + delta.composeAttributes(elem.attributes) + composed = composed.concat(deltasInRange) + else + console.assert(false, "Invalid delta in deltaB when composing", deltaB) + deltaC = new JetDelta(deltaA.startLength, deltaB.endLength, composed) + deltaC.compact() + console.assert(JetDelta.isDelta(deltaC), "Composed returning invalid JetDelta", deltaC) + return deltaC + + # We compute the follow according to the following rules: + # 1. Insertions in deltaA become retained characters in the follow set + # 2. Insertions in deltaB become inserted characters in the follow set + # 3. Characters retained in deltaA and deltaB become retained characters in + # the follow set + follows: (deltaA, deltaB, aIsRemote) -> + console.assert(JetDelta.isDelta(deltaA), "Follows called when deltaA is not a JetDelta, type: " + typeof deltaA, deltaA) + console.assert(JetDelta.isDelta(deltaB), "Follows called when deltaB is not a JetDelta, type: " + typeof deltaB, deltaB) + console.assert(aIsRemote?, "Remote delta not specified") + + deltaA = JetDelta.copy(deltaA) + deltaB = JetDelta.copy(deltaB) + deltaA.normalizeChanges() + deltaB.normalizeChanges() + followStartLength = deltaA.endLength + followSet = [] + indexA = indexB = 0 # Tracks character offset in the 'document' + elemIndexA = elemIndexB = 0 # Tracks offset into the deltas list + while elemIndexA < deltaA.deltas.length and elemIndexB < deltaB.deltas.length + elemA = deltaA.deltas[elemIndexA] + elemB = deltaB.deltas[elemIndexB] + + if JetDelta.isInsert(elemA) and JetDelta.isInsert(elemB) + length = Math.min(elemA.length, elemB.length) + if aIsRemote + followSet.push(new JetRetain(indexA, indexA + length)) + indexA += length + if length == elemA.length + elemIndexA++ + else + console.assert(length < elemA.length) + deltaA.deltas[elemIndexA] = new JetInsert(elemA.text.substring(length), elemA.attributes) + else + followSet.push(new JetInsert(elemB.text.substring(0, length), elemB.attributes)) + indexB += length + if length == elemB.length + elemIndexB++ + else + deltaB.deltas[elemIndexB] = new JetInsert(elemB.text.substring(length), elemB.attributes) + + else if JetDelta.isRetain(elemA) and JetDelta.isRetain(elemB) + if elemA.end < elemB.start + # Not a match, can't save. Throw away lower and adv. + indexA += elemA.end - elemA.start + elemIndexA++ + else if elemB.end < elemA.start + # Not a match, can't save. Throw away lower and adv. + indexB += elemB.end - elemB.start + elemIndexB++ + else + # A subrange or the entire range matches + if elemA.start < elemB.start + indexA += elemB.start - elemA.start + elemA = deltaA.deltas[elemIndexA] = new JetRetain(elemB.start, elemA.end, elemA.attributes) + else if elemB.start < elemA.start + indexB += elemA.start - elemB.start + elemB = deltaB.deltas[elemIndexB] = new JetRetain(elemA.start, elemB.end, elemB.attributes) + + console.assert(elemA.start == elemB.start, "JetRetains must have same + start length when propagating into followset", elemA, elemB) + length = Math.min(elemA.end, elemB.end) - elemA.start + addedAttributes = elemA.addAttributes(elemB.attributes) + followSet.push(new JetRetain(indexA, indexA + length, addedAttributes)) # Keep the retain + indexA += length + indexB += length + if (elemA.end == elemB.end) + elemIndexA++ + elemIndexB++ + else if (elemA.end < elemB.end) + elemIndexA++ + deltaB.deltas[elemIndexB] = new JetRetain(elemB.start + length, elemB.end, elemB.attributes) + else + deltaA.deltas[elemIndexA] = new JetRetain(elemA.start + length, elemA.end, elemA.attributes) + elemIndexB++ + + else if JetDelta.isInsert(elemA) and JetDelta.isRetain(elemB) + followSet.push(new JetRetain(indexA, indexA + elemA.length)) + indexA += elemA.length + elemIndexA++ + else if JetDelta.isRetain(elemA) and JetDelta.isInsert(elemB) + followSet.push(elemB) + indexB += elemB.length + elemIndexB++ + else + console.warn("Mismatch. elemA is: " + typeof(elemA) + ", elemB is: " + typeof(elemB)) + + # Remaining loops account for different length deltas, only inserts will be + # accepted + while elemIndexA < deltaA.deltas.length + elemA = deltaA.deltas[elemIndexA] + followSet.push(new JetRetain(indexA, indexA + elemA.length)) if JetDelta.isInsert(elemA) # retain elemA + if JetDelta.isInsert(elemA) then indexA += elemA.length else indexA += elemA.end - elemA.start + elemIndexA++ + + while elemIndexB < deltaB.deltas.length + elemB = deltaB.deltas[elemIndexB] + followSet.push(elemB) if JetDelta.isInsert(elemB) # insert elemB + if JetDelta.isInsert(elemB) then indexB += elemB.length else indexB += elemB.end - elemB.start + elemIndexB++ + + followEndLength = 0 + for elem in followSet + if JetDelta.isInsert(elem) + followEndLength += elem.length + else + followEndLength += elem.end - elem.start + + follow = new JetDelta(followStartLength, followEndLength, followSet, true) + follow.compact() + console.assert(JetDelta.isDelta(follow), "Follows returning invalid JetDelta", follow) + return follow + + applyDeltaToText: (delta, text) -> + console.assert(text.length == delta.startLength, "Start length of delta: " + delta.startLength + " is not equal to the text: " + text.length) + appliedText = [] + for elem in delta.deltas + if JetDelta.isInsert(elem) + appliedText.push(elem.text) + else + appliedText.push(text.substring(elem.start, elem.end)) + result = appliedText.join("") + if delta.endLength != result.length + console.log "Delta", delta + console.log "text", text + console.log "result", result + console.assert(false, "End length of delta: " + delta.endLength + " is not equal to result text: " + result.length ) + return result + +# Expose this code to other files +root = if (typeof exports != "undefined" && exports != null) then exports else window +root.JetRetain = JetRetain +root.JetInsert = JetInsert +root.JetDelta = JetDelta +root.JetSync = JetSync diff --git a/src/editor.coffee b/src/editor.coffee index 420a34a187..f81c1ffa5f 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -3,6 +3,7 @@ #= require selection #= require rangy-core #= require eventemitter2 +#= require jetsync class TandemEditor extends EventEmitter2 events: