From 2440f89be5055f3f9977c2d678635d782fc28aba Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Fri, 16 Feb 2024 14:38:07 +0100 Subject: [PATCH] [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 --- libraries/overleaf-editor-core/index.js | 3 + .../lib/{file_data => }/comment.js | 33 +++- .../lib/file_data/comment_list.js | 11 +- .../lib/file_data/string_file_data.js | 6 +- .../lib/file_data/tracked_change.js | 2 +- .../lib/file_data/tracked_change_list.js | 2 +- .../lib/operation/add_comment_operation.js | 102 +++++++++++ .../lib/operation/delete_comment_operation.js | 66 +++++++ .../lib/operation/edit_no_operation.js | 5 + .../lib/operation/edit_operation_builder.js | 37 +++- .../operation/edit_operation_transformer.js | 61 +++++++ .../lib/operation/text_operation.js | 2 +- .../lib/{file_data => }/range.js | 0 libraries/overleaf-editor-core/lib/types.ts | 14 +- .../test/add_comment_operation.test.js | 91 ++++++++++ .../overleaf-editor-core/test/comment.test.js | 4 +- .../test/comments_list.test.js | 14 +- .../test/delete_comment_operation.test.js | 49 +++++ .../test/edit_operation.test.js | 170 ++++++++++++++++++ .../overleaf-editor-core/test/range.test.js | 2 +- .../test/tracked_change.test.js | 2 +- 21 files changed, 650 insertions(+), 26 deletions(-) rename libraries/overleaf-editor-core/lib/{file_data => }/comment.js (82%) create mode 100644 libraries/overleaf-editor-core/lib/operation/add_comment_operation.js create mode 100644 libraries/overleaf-editor-core/lib/operation/delete_comment_operation.js create mode 100644 libraries/overleaf-editor-core/lib/operation/edit_no_operation.js rename libraries/overleaf-editor-core/lib/{file_data => }/range.js (100%) create mode 100644 libraries/overleaf-editor-core/test/add_comment_operation.test.js create mode 100644 libraries/overleaf-editor-core/test/delete_comment_operation.test.js diff --git a/libraries/overleaf-editor-core/index.js b/libraries/overleaf-editor-core/index.js index db1fda26f9..fc437c9d26 100644 --- a/libraries/overleaf-editor-core/index.js +++ b/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') diff --git a/libraries/overleaf-editor-core/lib/file_data/comment.js b/libraries/overleaf-editor-core/lib/comment.js similarity index 82% rename from libraries/overleaf-editor-core/lib/file_data/comment.js rename to libraries/overleaf-editor-core/lib/comment.js index 2c8483a199..b24a7cd781 100644 --- a/libraries/overleaf-editor-core/lib/file_data/comment.js +++ b/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} diff --git a/libraries/overleaf-editor-core/lib/file_data/comment_list.js b/libraries/overleaf-editor-core/lib/file_data/comment_list.js index 5452d2b288..4c22d7b596 100644 --- a/libraries/overleaf-editor-core/lib/file_data/comment_list.js +++ b/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() diff --git a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js index 73b7e27254..ebba0a685c 100644 --- a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js +++ b/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() diff --git a/libraries/overleaf-editor-core/lib/file_data/tracked_change.js b/libraries/overleaf-editor-core/lib/file_data/tracked_change.js index 7c10873ea1..955be73635 100644 --- a/libraries/overleaf-editor-core/lib/file_data/tracked_change.js +++ b/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') /** diff --git a/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js b/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js index b9264062b8..5320df5d7a 100644 --- a/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js +++ b/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') /** diff --git a/libraries/overleaf-editor-core/lib/operation/add_comment_operation.js b/libraries/overleaf-editor-core/lib/operation/add_comment_operation.js new file mode 100644 index 0000000000..73b38e8c43 --- /dev/null +++ b/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 diff --git a/libraries/overleaf-editor-core/lib/operation/delete_comment_operation.js b/libraries/overleaf-editor-core/lib/operation/delete_comment_operation.js new file mode 100644 index 0000000000..1a33df5441 --- /dev/null +++ b/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 diff --git a/libraries/overleaf-editor-core/lib/operation/edit_no_operation.js b/libraries/overleaf-editor-core/lib/operation/edit_no_operation.js new file mode 100644 index 0000000000..f2e99ccc84 --- /dev/null +++ b/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 diff --git a/libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js b/libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js index a4c80eff76..2ec278de4a 100644 --- a/libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js +++ b/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 diff --git a/libraries/overleaf-editor-core/lib/operation/edit_operation_transformer.js b/libraries/overleaf-editor-core/lib/operation/edit_operation_transformer.js index 5979712f23..b3fa32ac37 100644 --- a/libraries/overleaf-editor-core/lib/operation/edit_operation_transformer.js +++ b/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}` ) diff --git a/libraries/overleaf-editor-core/lib/operation/text_operation.js b/libraries/overleaf-editor-core/lib/operation/text_operation.js index 93d581b846..7efe7fb1d8 100644 --- a/libraries/overleaf-editor-core/lib/operation/text_operation.js +++ b/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 diff --git a/libraries/overleaf-editor-core/lib/file_data/range.js b/libraries/overleaf-editor-core/lib/range.js similarity index 100% rename from libraries/overleaf-editor-core/lib/file_data/range.js rename to libraries/overleaf-editor-core/lib/range.js diff --git a/libraries/overleaf-editor-core/lib/types.ts b/libraries/overleaf-editor-core/lib/types.ts index b2655485b5..11ba6baf7b 100644 --- a/libraries/overleaf-editor-core/lib/types.ts +++ b/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 diff --git a/libraries/overleaf-editor-core/test/add_comment_operation.test.js b/libraries/overleaf-editor-core/test/add_comment_operation.test.js new file mode 100644 index 0000000000..b7439b4b0c --- /dev/null +++ b/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) + }) +}) diff --git a/libraries/overleaf-editor-core/test/comment.test.js b/libraries/overleaf-editor-core/test/comment.test.js index 2dd24e157a..b2686bf4a6 100644 --- a/libraries/overleaf-editor-core/test/comment.test.js +++ b/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 () { diff --git a/libraries/overleaf-editor-core/test/comments_list.test.js b/libraries/overleaf-editor-core/test/comments_list.test.js index e28f7bbad3..8ea8a73176 100644 --- a/libraries/overleaf-editor-core/test/comments_list.test.js +++ b/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', diff --git a/libraries/overleaf-editor-core/test/delete_comment_operation.test.js b/libraries/overleaf-editor-core/test/delete_comment_operation.test.js new file mode 100644 index 0000000000..be5cc7f7cc --- /dev/null +++ b/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} */ ( + 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() + }) +}) diff --git a/libraries/overleaf-editor-core/test/edit_operation.test.js b/libraries/overleaf-editor-core/test/edit_operation.test.js index b7cc673526..82d5208ec7 100644 --- a/libraries/overleaf-editor-core/test/edit_operation.test.js +++ b/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 () { diff --git a/libraries/overleaf-editor-core/test/range.test.js b/libraries/overleaf-editor-core/test/range.test.js index c636511626..b9c6be6fe4 100644 --- a/libraries/overleaf-editor-core/test/range.test.js +++ b/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 () { diff --git a/libraries/overleaf-editor-core/test/tracked_change.test.js b/libraries/overleaf-editor-core/test/tracked_change.test.js index 953998143b..8837812a3f 100644 --- a/libraries/overleaf-editor-core/test/tracked_change.test.js +++ b/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')