Ver Fonte

Merge pull request #26209 from overleaf/em-multiple-edit-ops

Support multiple ops in the history OT ShareJS type

GitOrigin-RevId: fad1e9081ed1978de414c5130692d3b23fcd13d8
Eric Mc Sween há 1 ano atrás
pai
commit
6d202432ff

+ 35 - 4
services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts

@@ -30,18 +30,49 @@ export const historyOTType = {
   api,
 
   transformX(ops1: EditOperation[], ops2: EditOperation[]) {
-    const [a, b] = EditOperationTransformer.transform(ops1[0], ops2[0])
-    return [[a], [b]]
+    // Dynamic programming algorithm: gradually transform both sides in a nested
+    // loop.
+    const left = [...ops1]
+    const right = [...ops2]
+    for (let i = 0; i < left.length; i++) {
+      for (let j = 0; j < right.length; j++) {
+        // At this point:
+        // left[0..i] is ops1[0..i] rebased over ops2[0..j-1]
+        // right[0..j] is ops2[0..j] rebased over ops1[0..i-1]
+        const [a, b] = EditOperationTransformer.transform(left[i], right[j])
+        left[i] = a
+        right[j] = b
+      }
+    }
+    return [left, right]
   },
 
   apply(snapshot: StringFileData, ops: EditOperation[]) {
     const afterFile = StringFileData.fromRaw(snapshot.toRaw())
-    afterFile.edit(ops[0])
+    for (const op of ops) {
+      afterFile.edit(op)
+    }
     return afterFile
   },
 
   compose(ops1: EditOperation[], ops2: EditOperation[]) {
-    return [ops1[0].compose(ops2[0])]
+    const ops = [...ops1, ...ops2]
+    let currentOp = ops.shift()
+    if (currentOp === undefined) {
+      // No ops to process
+      return []
+    }
+    const result = []
+    for (const op of ops) {
+      if (currentOp.canBeComposedWith(op)) {
+        currentOp = currentOp.compose(op)
+      } else {
+        result.push(currentOp)
+        currentOp = op
+      }
+    }
+    result.push(currentOp)
+    return result
   },
 
   // Do not provide normalize, used by submitOp to fixup bad input.

+ 134 - 0
services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts

@@ -0,0 +1,134 @@
+import { expect } from 'chai'
+import {
+  StringFileData,
+  TextOperation,
+  AddCommentOperation,
+  Range,
+} from 'overleaf-editor-core'
+import { historyOTType } from '@/features/ide-react/editor/share-js-history-ot-type'
+
+describe('historyOTType', function () {
+  let snapshot: StringFileData
+  let opsA: TextOperation[]
+  let opsB: TextOperation[]
+
+  beforeEach(function () {
+    snapshot = new StringFileData('one plus two equals three')
+
+    // After opsA: "seven plus five equals twelve"
+    opsA = [new TextOperation(), new TextOperation(), new TextOperation()]
+
+    opsA[0].remove(3)
+    opsA[0].insert('seven')
+    opsA[0].retain(22)
+
+    opsA[1].retain(11)
+    opsA[1].remove(3)
+    opsA[1].insert('five')
+    opsA[1].retain(13)
+
+    opsA[2].retain(23)
+    opsA[2].remove(5)
+    opsA[2].insert('twelve')
+
+    // After ops2: "one times two equals two"
+    opsB = [new TextOperation(), new TextOperation()]
+
+    opsB[0].retain(4)
+    opsB[0].remove(4)
+    opsB[0].insert('times')
+    opsB[0].retain(17)
+
+    opsB[1].retain(21)
+    opsB[1].remove(5)
+    opsB[1].insert('two')
+  })
+
+  describe('apply', function () {
+    it('supports an empty operations array', function () {
+      const result = historyOTType.apply(snapshot, [])
+      expect(result.getContent()).to.equal('one plus two equals three')
+    })
+
+    it('applies operations to the snapshot (opsA)', function () {
+      const result = historyOTType.apply(snapshot, opsA)
+      expect(result.getContent()).to.equal('seven plus five equals twelve')
+    })
+
+    it('applies operations to the snapshot (opsB)', function () {
+      const result = historyOTType.apply(snapshot, opsB)
+      expect(result.getContent()).to.equal('one times two equals two')
+    })
+  })
+
+  describe('compose', function () {
+    it('supports empty operations', function () {
+      const ops = historyOTType.compose([], [])
+      expect(ops).to.deep.equal([])
+    })
+
+    it('supports an empty operation on the left', function () {
+      const ops = historyOTType.compose([], opsA)
+      const result = historyOTType.apply(snapshot, ops)
+      expect(result.getContent()).to.equal('seven plus five equals twelve')
+    })
+
+    it('supports an empty operation on the right', function () {
+      const ops = historyOTType.compose(opsA, [])
+      const result = historyOTType.apply(snapshot, ops)
+      expect(result.getContent()).to.equal('seven plus five equals twelve')
+    })
+
+    it('supports operations on both sides', function () {
+      const ops = historyOTType.compose(opsA.slice(0, 2), opsA.slice(2))
+      const result = historyOTType.apply(snapshot, ops)
+      expect(ops.length).to.equal(1)
+      expect(result.getContent()).to.equal('seven plus five equals twelve')
+    })
+
+    it("supports operations that can't be composed", function () {
+      const comment = new AddCommentOperation('comment-id', [new Range(3, 10)])
+      const ops = historyOTType.compose(opsA.slice(0, 2), [
+        comment,
+        ...opsA.slice(2),
+      ])
+      expect(ops.length).to.equal(3)
+      const result = historyOTType.apply(snapshot, ops)
+      expect(result.getContent()).to.equal('seven plus five equals twelve')
+    })
+  })
+
+  describe('transformX', function () {
+    it('supports empty operations', function () {
+      const [aPrime, bPrime] = historyOTType.transformX([], [])
+      expect(aPrime).to.deep.equal([])
+      expect(bPrime).to.deep.equal([])
+    })
+
+    it('supports an empty operation on the left', function () {
+      const [aPrime, bPrime] = historyOTType.transformX([], opsB)
+      expect(aPrime).to.deep.equal([])
+      expect(bPrime).to.deep.equal(opsB)
+    })
+
+    it('supports an empty operation on the right', function () {
+      const [aPrime, bPrime] = historyOTType.transformX(opsA, [])
+      expect(aPrime).to.deep.equal(opsA)
+      expect(bPrime).to.deep.equal([])
+    })
+
+    it('supports operations on both sides (a then b)', function () {
+      const [, bPrime] = historyOTType.transformX(opsA, opsB)
+      const ops = historyOTType.compose(opsA, bPrime)
+      const result = historyOTType.apply(snapshot, ops)
+      expect(result.getContent()).to.equal('seven times five equals twelvetwo')
+    })
+
+    it('supports operations on both sides (b then a)', function () {
+      const [aPrime] = historyOTType.transformX(opsA, opsB)
+      const ops = historyOTType.compose(opsB, aPrime)
+      const result = historyOTType.apply(snapshot, ops)
+      expect(result.getContent()).to.equal('seven times five equals twelvetwo')
+    })
+  })
+})