From 6d202432ffe5287d2d65b48689c00326401201cc Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Mon, 9 Jun 2025 15:28:12 -0400 Subject: [PATCH] Merge pull request #26209 from overleaf/em-multiple-edit-ops Support multiple ops in the history OT ShareJS type GitOrigin-RevId: fad1e9081ed1978de414c5130692d3b23fcd13d8 --- .../editor/share-js-history-ot-type.ts | 39 ++++- .../unit/share-js-history-ot-type.ts | 134 ++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 0e70e93676..81243bb8c7 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/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. diff --git a/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts b/services/web/test/frontend/features/ide-react/unit/share-js-history-ot-type.ts new file mode 100644 index 0000000000..8418c59ed0 --- /dev/null +++ b/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') + }) + }) +})