Explorar o código

[overleaf-editor-core] AddCommentOperation and DeleteCommentOperation (#16871)

* [overleaf-editor-core] AddCommentOperation and DeleteCommentOperation

* added add comment op test

* delete comment op test

* import core to escape circle deps

* desctructure in tests

* require directly in builder

* invert of add comment is always delete comment

* no merging on compose

* NoOp if comment is not found

* use comment.clone()

* update test

* change CommentRawData type

* jsdoc assert type

* fix formating

* EditNoOperation

* return other in compose

* use ReturnType

* Revert "use ReturnType"

This reverts commit 2c7e04f1541310e9fc08963170a783a437ed1992.

* transorm add comment operation

* transform delete comment operation

* moved comment.js

* format fix

* fix transform addComment and textoperation

* fix merge

* test more complex test operations

* change to else if

* move range.js

* fix types

* fix AddComment and TextOperation transform

* fixed AddComment-TextOperation trasform, added test

* deletecommentoperation should win

* should not delete comment

* remove unused function, fix type

* fix format

* add resolved for existing comment

* transform EditNoOperation

* fix test description

* change the order of EditNoOperation

* fix DeleteCommentOperation-DeleteCommentOperation transform

* fix types after merging main

* refactor operation types

GitOrigin-RevId: 6f127763a6dc50d4fe3524d9b25dc7526b6b0028
Domagoj Kriskovic %!s(int64=2) %!d(string=hai) anos
pai
achega
2440f89be5
Modificáronse 21 ficheiros con 650 adicións e 26 borrados
  1. 3 0
      libraries/overleaf-editor-core/index.js
  2. 32 1
      libraries/overleaf-editor-core/lib/comment.js
  3. 6 5
      libraries/overleaf-editor-core/lib/file_data/comment_list.js
  4. 3 3
      libraries/overleaf-editor-core/lib/file_data/string_file_data.js
  5. 1 1
      libraries/overleaf-editor-core/lib/file_data/tracked_change.js
  6. 1 1
      libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js
  7. 102 0
      libraries/overleaf-editor-core/lib/operation/add_comment_operation.js
  8. 66 0
      libraries/overleaf-editor-core/lib/operation/delete_comment_operation.js
  9. 5 0
      libraries/overleaf-editor-core/lib/operation/edit_no_operation.js
  10. 36 1
      libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js
  11. 61 0
      libraries/overleaf-editor-core/lib/operation/edit_operation_transformer.js
  12. 1 1
      libraries/overleaf-editor-core/lib/operation/text_operation.js
  13. 0 0
      libraries/overleaf-editor-core/lib/range.js
  14. 11 3
      libraries/overleaf-editor-core/lib/types.ts
  15. 91 0
      libraries/overleaf-editor-core/test/add_comment_operation.test.js
  16. 2 2
      libraries/overleaf-editor-core/test/comment.test.js
  17. 8 6
      libraries/overleaf-editor-core/test/comments_list.test.js
  18. 49 0
      libraries/overleaf-editor-core/test/delete_comment_operation.test.js
  19. 170 0
      libraries/overleaf-editor-core/test/edit_operation.test.js
  20. 1 1
      libraries/overleaf-editor-core/test/range.test.js
  21. 1 1
      libraries/overleaf-editor-core/test/tracked_change.test.js

+ 3 - 0
libraries/overleaf-editor-core/index.js

@@ -13,6 +13,9 @@ exports.Label = require('./lib/label')
 exports.AddFileOperation = require('./lib/operation/add_file_operation')
 exports.MoveFileOperation = require('./lib/operation/move_file_operation')
 exports.EditFileOperation = require('./lib/operation/edit_file_operation')
+exports.EditNoOperation = require('./lib/operation/edit_no_operation')
+exports.AddCommentOperation = require('./lib/operation/add_comment_operation')
+exports.DeleteCommentOperation = require('./lib/operation/delete_comment_operation')
 exports.SetFileMetadataOperation = require('./lib/operation/set_file_metadata_operation')
 exports.NoOperation = require('./lib/operation/no_operation')
 exports.Operation = require('./lib/operation')

+ 32 - 1
libraries/overleaf-editor-core/lib/file_data/comment.js → libraries/overleaf-editor-core/lib/comment.js

@@ -1,8 +1,10 @@
 // @ts-check
+const { RetainOp, InsertOp, RemoveOp } = require('./operation/scan_op')
 const Range = require('./range')
 
 /**
- * @typedef {import("../types").CommentRawData} CommentRawData
+ * @typedef {import("./types").CommentRawData} CommentRawData
+ * @typedef {import("./operation/text_operation")} TextOperation
  */
 
 class Comment {
@@ -118,10 +120,32 @@ class Comment {
     this.mergeRanges()
   }
 
+  /**
+   *
+   * @param {TextOperation} operation
+   */
+  applyTextOperation(operation) {
+    let cursor = 0
+    for (const op of operation.ops) {
+      if (op instanceof RetainOp) {
+        cursor += op.length
+      } else if (op instanceof InsertOp) {
+        this.applyInsert(cursor, op.insertion.length)
+        cursor += op.insertion.length
+      } else if (op instanceof RemoveOp) {
+        this.applyDelete(new Range(cursor, op.length))
+      }
+    }
+  }
+
   isEmpty() {
     return this.ranges.length === 0
   }
 
+  /**
+   *
+   * @returns {CommentRawData}
+   */
   toRaw() {
     return {
       resolved: this.resolved,
@@ -149,6 +173,13 @@ class Comment {
     this.ranges = mergedRanges
   }
 
+  /**
+   * @returns {Comment}
+   */
+  clone() {
+    return Comment.fromRaw(this.toRaw())
+  }
+
   /**
    * @param {CommentRawData} rawComment
    * @returns {Comment}

+ 6 - 5
libraries/overleaf-editor-core/lib/file_data/comment_list.js

@@ -1,9 +1,9 @@
 // @ts-check
-const Comment = require('./comment')
+const Comment = require('../comment')
 
 /**
- * @typedef {import("../types").CommentRawData} CommentRawData
- * @typedef {import("./range")} Range
+ * @typedef {import("../types").CommentsListRawData} CommentsListRawData
+ * @typedef {import("../range")} Range
  */
 
 class CommentList {
@@ -15,7 +15,7 @@ class CommentList {
   }
 
   /**
-   * @returns {CommentRawData[]}
+   * @returns {CommentsListRawData}
    */
   getComments() {
     return Array.from(this.comments).map(([commentId, comment]) => {
@@ -41,6 +41,7 @@ class CommentList {
   add(id, newComment) {
     const existingComment = this.getComment(id)
     if (existingComment) {
+      existingComment.resolved = existingComment.resolved && newComment.resolved
       for (const range of newComment.ranges) {
         existingComment.addRange(range)
       }
@@ -57,7 +58,7 @@ class CommentList {
   }
 
   /**
-   * @param {CommentRawData[]} rawComments
+   * @param {CommentsListRawData} rawComments
    */
   static fromRaw(rawComments) {
     const comments = new Map()

+ 3 - 3
libraries/overleaf-editor-core/lib/file_data/string_file_data.js

@@ -11,15 +11,15 @@ const TrackedChangeList = require('./tracked_change_list')
  * @typedef {import("../types").StringFileRawData} StringFileRawData
  * @typedef {import("../operation/edit_operation")} EditOperation
  * @typedef {import("../types").BlobStore} BlobStore
- * @typedef {import("../types").CommentRawData} CommentRawData
+ * @typedef {import("../types").CommentsListRawData} CommentsListRawData
  * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData
  */
 
 class StringFileData extends FileData {
   /**
    * @param {string} content
-   * @param {CommentRawData[] | undefined} [rawComments]
-   * @param {TrackedChangeRawData[] | undefined} [rawTrackedChanges]
+   * @param {CommentsListRawData} [rawComments]
+   * @param {TrackedChangeRawData[]} [rawTrackedChanges]
    */
   constructor(content, rawComments = [], rawTrackedChanges = []) {
     super()

+ 1 - 1
libraries/overleaf-editor-core/lib/file_data/tracked_change.js

@@ -1,5 +1,5 @@
 // @ts-check
-const Range = require('./range')
+const Range = require('../range')
 const TrackingProps = require('./tracking_props')
 
 /**

+ 1 - 1
libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js

@@ -1,5 +1,5 @@
 // @ts-check
-const Range = require('./range')
+const Range = require('../range')
 const TrackedChange = require('./tracked_change')
 
 /**

+ 102 - 0
libraries/overleaf-editor-core/lib/operation/add_comment_operation.js

@@ -0,0 +1,102 @@
+// @ts-check
+const core = require('../../index')
+const Comment = require('../comment')
+const EditOperation = require('./edit_operation')
+
+/**
+ * @typedef {import('./delete_comment_operation')} DeleteCommentOperation
+ * @typedef {import('../types').CommentRawData} CommentRawData
+ * @typedef {import('../types').RawAddCommentOperation} RawAddCommentOperation
+ * @typedef {import('../file_data/string_file_data')} StringFileData
+ */
+
+/**
+ * @extends EditOperation
+ */
+class AddCommentOperation extends EditOperation {
+  /**
+   * @param {string} commentId
+   * @param {Comment} comment
+   */
+  constructor(commentId, comment) {
+    super()
+    this.commentId = commentId
+    this.comment = comment
+  }
+
+  /**
+   *
+   * @returns {RawAddCommentOperation}
+   */
+  toJSON() {
+    return {
+      ...this.comment.toRaw(),
+      commentId: this.commentId,
+    }
+  }
+
+  /**
+   * @param {StringFileData} fileData
+   */
+  apply(fileData) {
+    fileData.comments.add(this.commentId, this.comment)
+  }
+
+  /**
+   *
+   * @returns {DeleteCommentOperation}
+   */
+  invert() {
+    return new core.DeleteCommentOperation(this.commentId)
+  }
+
+  /**
+   * @inheritdoc
+   * @param {EditOperation} other
+   * @returns {boolean}
+   */
+  canBeComposedWith(other) {
+    return (
+      (other instanceof AddCommentOperation &&
+        this.commentId === other.commentId) ||
+      (other instanceof core.DeleteCommentOperation &&
+        this.commentId === other.commentId)
+    )
+  }
+
+  /**
+   * @inheritdoc
+   * @param {EditOperation} other
+   * @returns {EditOperation}
+   */
+  compose(other) {
+    if (
+      other instanceof core.DeleteCommentOperation &&
+      other.commentId === this.commentId
+    ) {
+      return other
+    }
+
+    if (
+      other instanceof AddCommentOperation &&
+      other.commentId === this.commentId
+    ) {
+      return other
+    }
+
+    throw new Error(
+      `Trying to compose AddCommentOperation with ${other?.constructor?.name}.`
+    )
+  }
+
+  /**
+   * @inheritdoc
+   * @param {RawAddCommentOperation} raw
+   * @returns {AddCommentOperation}
+   */
+  static fromJSON(raw) {
+    return new AddCommentOperation(raw.commentId, Comment.fromRaw(raw))
+  }
+}
+
+module.exports = AddCommentOperation

+ 66 - 0
libraries/overleaf-editor-core/lib/operation/delete_comment_operation.js

@@ -0,0 +1,66 @@
+// @ts-check
+const core = require('../../index')
+const EditNoOperation = require('./edit_no_operation')
+const EditOperation = require('./edit_operation')
+
+/**
+ * @typedef {import('./add_comment_operation')} AddCommentOperation
+ * @typedef {import('../types').RawDeleteCommentOperation} RawDeleteCommentOperation
+ * @typedef {import('../file_data/string_file_data')} StringFileData
+ */
+
+/**
+ * @extends EditOperation
+ */
+class DeleteCommentOperation extends EditOperation {
+  /**
+   * @param {string} commentId
+   */
+  constructor(commentId) {
+    super()
+    this.commentId = commentId
+  }
+
+  /**
+   * @inheritdoc
+   * @returns {RawDeleteCommentOperation}
+   */
+  toJSON() {
+    return {
+      deleteComment: this.commentId,
+    }
+  }
+
+  /**
+   * @inheritdoc
+   * @param {StringFileData} fileData
+   */
+  apply(fileData) {
+    fileData.comments.delete(this.commentId)
+  }
+
+  /**
+   * @inheritdoc
+   * @param {StringFileData} previousState
+   * @returns {AddCommentOperation | EditNoOperation}
+   */
+  invert(previousState) {
+    const comment = previousState.comments.getComment(this.commentId)
+    if (!comment) {
+      return new EditNoOperation()
+    }
+
+    return new core.AddCommentOperation(this.commentId, comment.clone())
+  }
+
+  /**
+   * @inheritdoc
+   * @param {RawDeleteCommentOperation} raw
+   * @returns {DeleteCommentOperation}
+   */
+  static fromJSON(raw) {
+    return new DeleteCommentOperation(raw.deleteComment)
+  }
+}
+
+module.exports = DeleteCommentOperation

+ 5 - 0
libraries/overleaf-editor-core/lib/operation/edit_no_operation.js

@@ -0,0 +1,5 @@
+const EditOperation = require('./edit_operation')
+
+class EditNoOperation extends EditOperation {}
+
+module.exports = EditNoOperation

+ 36 - 1
libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js

@@ -1,8 +1,13 @@
 // @ts-check
 /**
  * @typedef {import('./edit_operation')} EditOperation
+ * @typedef {import('../types').RawTextOperation} RawTextOperation
+ * @typedef {import('../types').RawAddCommentOperation} RawAddCommentOperation
+ * @typedef {import('../types').RawDeleteCommentOperation} RawDeleteCommentOperation
  * @typedef {import('../types').RawEditOperation} RawEditOperation
  */
+const DeleteCommentOperation = require('./delete_comment_operation')
+const AddCommentOperation = require('./add_comment_operation')
 const TextOperation = require('./text_operation')
 
 class EditOperationBuilder {
@@ -12,11 +17,41 @@ class EditOperationBuilder {
    * @returns {EditOperation}
    */
   static fromJSON(raw) {
-    if (raw.textOperation) {
+    if (isTextOperation(raw)) {
       return TextOperation.fromJSON(raw)
     }
+    if (isRawAddCommentOperation(raw)) {
+      return AddCommentOperation.fromJSON(raw)
+    }
+    if (isRawDeleteCommentOperation(raw)) {
+      return DeleteCommentOperation.fromJSON(raw)
+    }
     throw new Error('Unsupported operation in EditOperationBuilder.fromJSON')
   }
 }
 
+/**
+ * @param {*} raw
+ * @returns {raw is RawTextOperation}
+ */
+function isTextOperation(raw) {
+  return raw?.textOperation !== undefined
+}
+
+/**
+ * @param {*} raw
+ * @returns {raw is RawAddCommentOperation}
+ */
+function isRawAddCommentOperation(raw) {
+  return raw?.commentId && Array.isArray(raw.ranges)
+}
+
+/**
+ * @param {*} raw
+ * @returns {raw is RawDeleteCommentOperation}
+ */
+function isRawDeleteCommentOperation(raw) {
+  return raw?.deleteComment
+}
+
 module.exports = EditOperationBuilder

+ 61 - 0
libraries/overleaf-editor-core/lib/operation/edit_operation_transformer.js

@@ -1,4 +1,6 @@
 // @ts-check
+const core = require('../..')
+const EditNoOperation = require('./edit_no_operation')
 const TextOperation = require('./text_operation')
 /** @typedef {import('./edit_operation')} EditOperation */
 
@@ -7,11 +9,70 @@ class EditOperationTransformer {
    * Transform two edit operations against each other.
    * @param {EditOperation} a
    * @param {EditOperation} b
+   * @returns {[EditOperation, EditOperation]}
    */
   static transform(a, b) {
+    const { AddCommentOperation, DeleteCommentOperation } = core
+
+    if (a instanceof EditNoOperation || b instanceof EditNoOperation) {
+      return [a, b]
+    }
+
     if (a instanceof TextOperation && b instanceof TextOperation) {
       return TextOperation.transform(a, b)
     }
+
+    if (a instanceof TextOperation && b instanceof DeleteCommentOperation) {
+      return [a, b]
+    }
+
+    if (a instanceof DeleteCommentOperation && b instanceof TextOperation) {
+      return [a, b]
+    }
+
+    if (a instanceof AddCommentOperation && b instanceof TextOperation) {
+      const comment = a.comment.clone()
+      comment.applyTextOperation(b)
+      return [new AddCommentOperation(a.commentId, comment), b]
+    }
+
+    if (a instanceof TextOperation && b instanceof AddCommentOperation) {
+      const comment = b.comment.clone()
+      comment.applyTextOperation(a)
+      return [a, new AddCommentOperation(b.commentId, comment)]
+    }
+
+    if (a instanceof AddCommentOperation && b instanceof AddCommentOperation) {
+      return [a, b]
+    }
+    if (
+      a instanceof DeleteCommentOperation &&
+      b instanceof AddCommentOperation
+    ) {
+      if (a.commentId === b.commentId) {
+        return [a, new EditNoOperation()]
+      }
+      return [a, b]
+    }
+    if (
+      a instanceof AddCommentOperation &&
+      b instanceof DeleteCommentOperation
+    ) {
+      if (a.commentId === b.commentId) {
+        return [new EditNoOperation(), b]
+      }
+      return [a, b]
+    }
+    if (
+      a instanceof DeleteCommentOperation &&
+      b instanceof DeleteCommentOperation
+    ) {
+      if (a.commentId === b.commentId) {
+        return [new EditNoOperation(), new EditNoOperation()]
+      }
+      return [a, b]
+    }
+
     throw new Error(
       `Transform not implemented for ${a.constructor.name}○${b.constructor.name}`
     )

+ 1 - 1
libraries/overleaf-editor-core/lib/operation/text_operation.js

@@ -25,7 +25,7 @@ const {
   InvalidInsertionError,
   TooLongError,
 } = require('../errors')
-const Range = require('../file_data/range')
+const Range = require('../range')
 const TrackingProps = require('../file_data/tracking_props')
 /**
  * @typedef {import('../file_data/string_file_data')} StringFileData

+ 0 - 0
libraries/overleaf-editor-core/lib/file_data/range.js → libraries/overleaf-editor-core/lib/range.js


+ 11 - 3
libraries/overleaf-editor-core/lib/types.ts

@@ -11,7 +11,6 @@ type Range = {
 }
 
 export type CommentRawData = {
-  id: string
   ranges: Range[]
   resolved?: boolean
 }
@@ -27,9 +26,11 @@ export type TrackingPropsRawData = {
   ts: string
 }
 
+export type CommentsListRawData = Array<{ id: string } & CommentRawData>
+
 export type StringFileRawData = {
   content: string
-  comments?: CommentRawData[]
+  comments?: CommentsListRawData
   trackedChanges?: TrackedChangeRawData[]
 }
 
@@ -58,4 +59,11 @@ export type RawTextOperation = {
   textOperation: RawScanOp[]
 }
 
-export type RawEditOperation = RawTextOperation
+export type RawAddCommentOperation = CommentRawData & { commentId: string }
+
+export type RawDeleteCommentOperation = { deleteComment: string }
+
+export type RawEditOperation =
+  | RawTextOperation
+  | RawAddCommentOperation
+  | RawDeleteCommentOperation

+ 91 - 0
libraries/overleaf-editor-core/test/add_comment_operation.test.js

@@ -0,0 +1,91 @@
+// @ts-check
+const { expect } = require('chai')
+const { AddCommentOperation, DeleteCommentOperation } = require('..')
+const Comment = require('../lib/comment')
+const StringFileData = require('../lib/file_data/string_file_data')
+
+describe('AddCommentOperation', function () {
+  it('constructs an AddCommentOperation fromJSON', function () {
+    const op = AddCommentOperation.fromJSON({
+      commentId: '123',
+      resolved: true,
+      ranges: [{ pos: 0, length: 1 }],
+    })
+    expect(op).to.be.instanceOf(AddCommentOperation)
+    expect(op.commentId).to.equal('123')
+    expect(op.comment).to.be.instanceOf(Comment)
+    expect(op.comment.resolved).to.be.true
+  })
+
+  it('should convert to JSON', function () {
+    const op = new AddCommentOperation(
+      '123',
+      Comment.fromRaw({
+        ranges: [
+          {
+            pos: 0,
+            length: 1,
+          },
+        ],
+      })
+    )
+    expect(op.toJSON()).to.eql({
+      commentId: '123',
+      resolved: false,
+      ranges: [
+        {
+          pos: 0,
+          length: 1,
+        },
+      ],
+    })
+  })
+
+  it('should apply operation', function () {
+    const fileData = new StringFileData('abc')
+    const op = new AddCommentOperation(
+      '123',
+      Comment.fromRaw({ ranges: [{ pos: 0, length: 1 }] })
+    )
+    op.apply(fileData)
+    expect(fileData.getComments()).to.eql([
+      {
+        id: '123',
+        ranges: [{ pos: 0, length: 1 }],
+        resolved: false,
+      },
+    ])
+  })
+
+  it('should invert operation', function () {
+    const fileData = new StringFileData('abc')
+    const op = new AddCommentOperation(
+      '123',
+      Comment.fromRaw({ ranges: [{ pos: 0, length: 1 }] })
+    )
+    op.apply(fileData)
+    expect(fileData.getComments()).to.eql([
+      {
+        id: '123',
+        ranges: [{ pos: 0, length: 1 }],
+        resolved: false,
+      },
+    ])
+
+    const invertedOp = op.invert()
+    invertedOp.apply(fileData)
+    expect(fileData.getComments()).to.eql([])
+  })
+
+  it('should compose with DeleteCommentOperation', function () {
+    const addOp = new AddCommentOperation(
+      '123',
+      Comment.fromRaw({ ranges: [{ pos: 0, length: 1 }] })
+    )
+    const deleteOp = new DeleteCommentOperation('123')
+    expect(addOp.canBeComposedWith(deleteOp)).to.be.true
+
+    const composedOp = addOp.compose(deleteOp)
+    expect(composedOp).to.be.instanceOf(DeleteCommentOperation)
+  })
+})

+ 2 - 2
libraries/overleaf-editor-core/test/comment.test.js

@@ -2,8 +2,8 @@
 'use strict'
 
 const { expect } = require('chai')
-const Comment = require('../lib/file_data/comment')
-const Range = require('../lib/file_data/range')
+const Comment = require('../lib/comment')
+const Range = require('../lib/range')
 
 describe('Comment', function () {
   it('should move ranges to the right of insert', function () {

+ 8 - 6
libraries/overleaf-editor-core/test/comments_list.test.js

@@ -3,8 +3,8 @@
 
 const { expect } = require('chai')
 const CommentList = require('../lib/file_data/comment_list')
-const Comment = require('../lib/file_data/comment')
-const Range = require('../lib/file_data/range')
+const Comment = require('../lib/comment')
+const Range = require('../lib/range')
 
 describe('commentList', function () {
   it('checks if toRaw() returns a correct comment list', function () {
@@ -77,13 +77,15 @@ describe('commentList', function () {
   it('should add range to existing if a new comment has the same id', function () {
     const commentList = new CommentList(
       new Map([
-        ['comm1', new Comment([new Range(5, 10)])],
-        ['comm2', new Comment([new Range(20, 5)])],
+        ['comm1', new Comment([new Range(5, 10)], false)],
+        ['comm2', new Comment([new Range(20, 5)], true)],
         ['comm3', new Comment([new Range(30, 15)])],
       ])
     )
 
-    commentList.add('comm2', new Comment([new Range(40, 10)]))
+    commentList.add('comm1', new Comment([new Range(5, 10)], true))
+    commentList.add('comm2', new Comment([new Range(40, 10)], true))
+
     expect(commentList.getComments()).to.eql([
       { id: 'comm1', ranges: [{ pos: 5, length: 10 }], resolved: false },
       {
@@ -92,7 +94,7 @@ describe('commentList', function () {
           { pos: 20, length: 5 },
           { pos: 40, length: 10 },
         ],
-        resolved: false,
+        resolved: true,
       },
       {
         id: 'comm3',

+ 49 - 0
libraries/overleaf-editor-core/test/delete_comment_operation.test.js

@@ -0,0 +1,49 @@
+// @ts-check
+const { expect } = require('chai')
+const { AddCommentOperation, DeleteCommentOperation } = require('..')
+const Comment = require('../lib/comment')
+const StringFileData = require('../lib/file_data/string_file_data')
+const Range = require('../lib/range')
+
+describe('DeleteCommentOperation', function () {
+  it('constructs an DeleteCommentOperation fromJSON', function () {
+    const op = DeleteCommentOperation.fromJSON({
+      deleteComment: '123',
+    })
+    expect(op).to.be.instanceOf(DeleteCommentOperation)
+  })
+
+  it('should convert to JSON', function () {
+    const op = new DeleteCommentOperation('123')
+    expect(op.toJSON()).to.eql({
+      deleteComment: '123',
+    })
+  })
+
+  it('should apply operation', function () {
+    const fileData = new StringFileData('abc')
+    const op = new DeleteCommentOperation('123')
+    fileData.comments.add('123', new Comment([new Range(0, 1)]))
+    op.apply(fileData)
+    expect(fileData.getComments()).to.eql([])
+  })
+
+  it('should invert operation', function () {
+    const fileData = new StringFileData('abc')
+    const op = new DeleteCommentOperation('123')
+    fileData.comments.add('123', new Comment([new Range(0, 1)]))
+    const invertedOp = /** @type {InstanceType<AddCommentOperation>} */ (
+      op.invert(fileData)
+    )
+    expect(invertedOp).to.be.instanceOf(AddCommentOperation)
+    expect(invertedOp.commentId).to.equal('123')
+    expect(invertedOp.comment).to.be.instanceOf(Comment)
+    expect(invertedOp.comment.ranges).to.eql([new Range(0, 1)])
+  })
+
+  it('should not throw if comment not found', function () {
+    const fileData = new StringFileData('abc')
+    const op = new DeleteCommentOperation('123')
+    expect(() => op.invert(fileData)).to.not.throw()
+  })
+})

+ 170 - 0
libraries/overleaf-editor-core/test/edit_operation.test.js

@@ -5,6 +5,11 @@ const EditOperationTransformer = require('../lib/operation/edit_operation_transf
 const EditOperation = require('../lib/operation/edit_operation')
 const randomTextOperation = require('./support/random_text_operation')
 const random = require('./support/random')
+const AddCommentOperation = require('../lib/operation/add_comment_operation')
+const DeleteCommentOperation = require('../lib/operation/delete_comment_operation')
+const Comment = require('../lib/comment')
+const Range = require('../lib/range')
+const EditNoOperation = require('../lib/operation/edit_no_operation')
 
 describe('EditOperation', function () {
   it('Cannot be instantiated', function () {
@@ -22,6 +27,171 @@ describe('EditOperationTransformer', function () {
     expect(aPrime).to.be.an.instanceof(TextOperation)
     expect(bPrime).to.be.an.instanceof(TextOperation)
   })
+
+  it('Transforms TextOperation and EditNoOperation', function () {
+    const a = new TextOperation().insert('foo')
+    const b = new EditNoOperation()
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(TextOperation)
+    expect(bPrime).to.be.an.instanceof(EditNoOperation)
+  })
+
+  it('Transforms two AddCommentOperations with same commentId', function () {
+    const a = new AddCommentOperation('comm1', new Comment([new Range(0, 1)]))
+    const b = new AddCommentOperation('comm1', new Comment([new Range(2, 3)]))
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(bPrime).to.be.an.instanceof(AddCommentOperation)
+  })
+
+  it('Transforms two AddCommentOperations with different commentId', function () {
+    const a = new AddCommentOperation('comm1', new Comment([new Range(0, 1)]))
+    const b = new AddCommentOperation('comm2', new Comment([new Range(2, 3)]))
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(bPrime.toJSON()).to.eql(b.toJSON())
+  })
+
+  it('Transforms two DeleteCommentOperations with same commentId', function () {
+    const a = new DeleteCommentOperation('comm1')
+    const b = new DeleteCommentOperation('comm1')
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(EditNoOperation)
+    expect(bPrime).to.be.an.instanceof(EditNoOperation)
+  })
+
+  it('Transforms two DeleteCommentOperations with different commentId', function () {
+    const a = new DeleteCommentOperation('comm1')
+    const b = new DeleteCommentOperation('comm2')
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(DeleteCommentOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(DeleteCommentOperation)
+    expect(bPrime.toJSON()).to.eql(b.toJSON())
+  })
+
+  it('Transforms AddCommentOperation and DeleteCommentOperation with same commentId', function () {
+    const a = new AddCommentOperation('comm1', new Comment([new Range(0, 1)]))
+    const b = new DeleteCommentOperation('comm1')
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(EditNoOperation)
+    expect(bPrime).to.be.an.instanceof(DeleteCommentOperation)
+    expect(bPrime.toJSON()).to.eql(b.toJSON())
+  })
+
+  it('Transforms DeleteCommentOperation and AddCommentOperation with same commentId', function () {
+    const a = new DeleteCommentOperation('comm1')
+    const b = new AddCommentOperation('comm1', new Comment([new Range(0, 1)]))
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(DeleteCommentOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(EditNoOperation)
+  })
+
+  it('Transforms AddCommentOperation and TextOperation', function () {
+    // abc hello[ world] xyz - insert(9, " world")
+    // abc hello |xyz| -   addComment(10, 3, "comment_id")
+
+    const a = new TextOperation().retain(9).insert(' world')
+    const b = new AddCommentOperation('comm1', new Comment([new Range(10, 3)]))
+
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(TextOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(bPrime.toJSON()).to.eql({
+      commentId: 'comm1',
+      ranges: [{ pos: 16, length: 3 }],
+      resolved: false,
+    })
+  })
+
+  it('Transforms TextOperation and AddCommentOperation', function () {
+    // abc hello |xyz| -   addComment(10, 3, "comment_id")
+    // abc hello[ world] xyz - insert(9, " world")
+
+    const a = new AddCommentOperation('comm1', new Comment([new Range(10, 3)]))
+    const b = new TextOperation().retain(9).insert(' world')
+
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(bPrime).to.be.an.instanceof(TextOperation)
+    expect(bPrime.toJSON()).to.eql(b.toJSON())
+    expect(aPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(aPrime.toJSON()).to.eql({
+      commentId: 'comm1',
+      ranges: [{ pos: 16, length: 3 }],
+      resolved: false,
+    })
+  })
+
+  it('Transforms AddCommentOperation and TextOperation that makes a detached comment', function () {
+    // [abc hello xyz] - delete(0, 13)
+    // abc |hello| xyz - addComment(5, 5, "comment_id")
+
+    const a = new TextOperation().remove(13)
+    const b = new AddCommentOperation('comm1', new Comment([new Range(5, 5)]))
+
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(TextOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(bPrime.toJSON()).to.eql({
+      commentId: 'comm1',
+      ranges: [],
+      resolved: false,
+    })
+  })
+
+  it('Transforms AddCommentOperation and deletion TextOperation', function () {
+    // abc hell{o xy}z - retain(8).delete(4)
+    // abc hello |xyz| -   addComment(10, 3, "comment_id")
+    // abc hell|z|
+
+    const a = new TextOperation().retain(8).remove(4)
+    const b = new AddCommentOperation('comm1', new Comment([new Range(10, 3)]))
+
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(TextOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(bPrime.toJSON()).to.eql({
+      commentId: 'comm1',
+      ranges: [{ pos: 8, length: 1 }],
+      resolved: false,
+    })
+  })
+
+  it('Transforms AddCommentOperation and complex TextOperation', function () {
+    // [foo ]abc hell{o xy}z - insert(0, "foo ").retain(8).delete(4)
+    // abc hello |xyz| -   addComment(10, 3, "comment_id")
+    // foo abc hell|z|
+
+    const a = new TextOperation().insert('foo ').retain(8).remove(4)
+    const b = new AddCommentOperation('comm1', new Comment([new Range(10, 3)]))
+
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(TextOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(AddCommentOperation)
+    expect(bPrime.toJSON()).to.eql({
+      commentId: 'comm1',
+      ranges: [{ pos: 12, length: 1 }],
+      resolved: false,
+    })
+  })
+
+  it('Transforms DeleteCommentOperation and TextOperation', function () {
+    const a = new TextOperation().retain(9).insert(' world')
+    const b = new DeleteCommentOperation('comm1')
+
+    const [aPrime, bPrime] = EditOperationTransformer.transform(a, b)
+    expect(aPrime).to.be.an.instanceof(TextOperation)
+    expect(aPrime.toJSON()).to.eql(a.toJSON())
+    expect(bPrime).to.be.an.instanceof(DeleteCommentOperation)
+    expect(bPrime.toJSON()).to.eql(b.toJSON())
+  })
 })
 
 describe('EditOperationBuilder', function () {

+ 1 - 1
libraries/overleaf-editor-core/test/range.test.js

@@ -2,7 +2,7 @@
 'use strict'
 
 const { expect } = require('chai')
-const Range = require('../lib/file_data/range')
+const Range = require('../lib/range')
 
 describe('Range', function () {
   it('should create a range', function () {

+ 1 - 1
libraries/overleaf-editor-core/test/tracked_change.test.js

@@ -1,6 +1,6 @@
 // @ts-check
 const TrackedChange = require('../lib/file_data/tracked_change')
-const Range = require('../lib/file_data/range')
+const Range = require('../lib/range')
 const TrackingProps = require('../lib/file_data/tracking_props')
 const { expect } = require('chai')