diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index d6b8262b18..9e763e964e 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -868,11 +868,21 @@ const createOrUpdateTrackedChangeComment = ({ event, marks, deletionNodes, nodes // When isDeletionInsertion is true, nodesWithMark should contain both types let nodesToUse; if (isDeletionInsertion) { - // For replacements, use nodes found in document (which should include both insertion and deletion) - // Also include nodes from step.slice and deletionNodes if they exist (for newly created replacements) - const allNodes = [...nodesWithMark, ...nodes, ...(deletionNodes || [])]; + // For replacements, prefer nodes found in the document to avoid duplicating text + // when step.slice/deletionNodes include overlapping content. + const hasInsertNode = nodesWithMark.some((node) => + node.marks.find((nodeMark) => nodeMark.type.name === TrackInsertMarkName), + ); + const hasDeleteNode = nodesWithMark.some((node) => + node.marks.find((nodeMark) => nodeMark.type.name === TrackDeleteMarkName), + ); + + const fallbackNodes = []; + if (!hasInsertNode && nodes?.length) fallbackNodes.push(...nodes); + if (!hasDeleteNode && deletionNodes?.length) fallbackNodes.push(...deletionNodes); + // Remove duplicates by comparing node identity - nodesToUse = Array.from(new Set(allNodes)); + nodesToUse = Array.from(new Set([...nodesWithMark, ...fallbackNodes])); } else { // For non-replacements, use nodes found in document or fall back to step nodes nodesToUse = nodesWithMark.length ? nodesWithMark : node ? [node] : []; diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.test.js b/packages/super-editor/src/extensions/comment/comments-plugin.test.js index f22b5e6861..76a185d79f 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.test.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.test.js @@ -916,6 +916,44 @@ describe('internal helper functions', () => { expect(combinedResult.deletionText).toBe('Removed'); }); + it('does not duplicate replacement text when creating tracked change comments', () => { + const schema = createCommentSchema(); + const insertMark = schema.marks[TrackInsertMarkName].create({ + id: 'replace-1', + author: 'Author', + authorEmail: 'author@example.com', + date: 'today', + }); + const deleteMark = schema.marks[TrackDeleteMarkName].create({ + id: 'replace-1', + author: 'Author', + authorEmail: 'author@example.com', + date: 'today', + }); + + const docInsertNode = schema.text('replacement', [insertMark]); + const docDeleteNode = schema.text('original', [deleteMark]); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [docInsertNode, docDeleteNode])]); + const state = EditorState.create({ schema, doc }); + + // Simulate step slice and deletion nodes from a replacement transaction + const stepInsertNodes = [schema.text('replacement', [insertMark])]; + const deletionNodes = [schema.text('original', [deleteMark])]; + + const payload = createOrUpdateTrackedChangeComment({ + event: 'add', + marks: { insertedMark: insertMark, deletionMark: deleteMark, formatMark: null }, + deletionNodes, + nodes: stepInsertNodes, + newEditorState: state, + documentId: 'doc-1', + }); + + expect(payload?.trackedChangeText).toBe('replacement'); + expect(payload?.trackedChangeText).not.toBe('replacementt'); + expect(payload?.deletedText).toBe('original'); + }); + it('createOrUpdateTrackedChangeComment builds add and update payloads', () => { const schema = createCommentSchema(); const insertMark = schema.marks[TrackInsertMarkName].create({