From f44983dc8b989b24ec06a1dc3b3d8d46afa7c403 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Tue, 3 Feb 2026 21:18:43 -0800 Subject: [PATCH 1/3] fix: ensure we do not duplicate bubble text --- .../src/extensions/comment/comments-plugin.js | 18 +++++++-- .../comment/comments-plugin.test.js | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) 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({ From f0a38090d6fd1a556e867f1be046e2fd2f026ade Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Wed, 11 Feb 2026 20:50:09 -0800 Subject: [PATCH 2/3] chore: small comments --- .../src/extensions/comment/comments-plugin.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index 9e763e964e..32eb99bca4 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -877,11 +877,12 @@ const createOrUpdateTrackedChangeComment = ({ event, marks, deletionNodes, nodes 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 + const fallbackNodes = [ + ...(!hasInsertNode && nodes?.length ? nodes : []), + ...(!hasDeleteNode && deletionNodes?.length ? deletionNodes : []), + ]; + // safety net for identity dedupe + // work is done above nodesToUse = Array.from(new Set([...nodesWithMark, ...fallbackNodes])); } else { // For non-replacements, use nodes found in document or fall back to step nodes From e6cc6f02b687febfc79ae9d271692189ed2b1c2d Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Wed, 11 Feb 2026 22:01:02 -0800 Subject: [PATCH 3/3] test: add vrt --- e2e-tests/tests/visuals/comments.spec.js | 40 ++++++++++++++++++ ...ange-replacement-bubble-chromium-linux.png | Bin 0 -> 8279 bytes 2 files changed, 40 insertions(+) create mode 100644 e2e-tests/tests/visuals/comments.spec.js-snapshots/tracked-change-replacement-bubble-chromium-linux.png diff --git a/e2e-tests/tests/visuals/comments.spec.js b/e2e-tests/tests/visuals/comments.spec.js index 1c300c70dc..a0e43ab7c8 100644 --- a/e2e-tests/tests/visuals/comments.spec.js +++ b/e2e-tests/tests/visuals/comments.spec.js @@ -74,4 +74,44 @@ test.describe('viewing mode comments visibility', () => { timeout: 30_000, }); }); + + test('should show inserted and removed text in tracked change replacement bubble', async ({ page }) => { + await goToPageAndWaitForEditor(page, { includeComments: true }); + await page.locator('input[type="file"]').setInputFiles(`${config.commentsDocumentsFolder}/basic-comments.docx`); + + await page.waitForFunction(() => window.superdoc !== undefined && window.editor !== undefined, null, { + polling: 100, + timeout: 10_000, + }); + + const superEditor = page.locator('div.super-editor').first(); + const targetText = 'replaced_token'; + const insertedText = 'inserted_token'; + + await superEditor.click(); + await page.keyboard.type(` ${targetText}`); + for (let i = 0; i < targetText.length; i += 1) { + await page.keyboard.press('Shift+ArrowLeft'); + } + + const trackChangesToggled = await page.evaluate(() => window.editor.commands.toggleTrackChanges()); + expect(trackChangesToggled).toBe(true); + await page.keyboard.type(insertedText); + await sleep(600); + + const replacementBubble = page + .getByRole('dialog') + .filter({ hasText: 'Added:' }) + .filter({ hasText: 'inserted_token' }) + .filter({ hasText: 'Deleted:' }) + .filter({ hasText: 'replaced_token' }) + .first(); + + await expect(replacementBubble).toBeVisible(); + await expect(replacementBubble).toContainText('Added:'); + await expect(replacementBubble).toContainText('inserted_token'); + await expect(replacementBubble).toContainText('Deleted:'); + await expect(replacementBubble).toContainText('replaced_token'); + await expect(replacementBubble).toHaveScreenshot('tracked-change-replacement-bubble.png'); + }); }); diff --git a/e2e-tests/tests/visuals/comments.spec.js-snapshots/tracked-change-replacement-bubble-chromium-linux.png b/e2e-tests/tests/visuals/comments.spec.js-snapshots/tracked-change-replacement-bubble-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..1e6794ab1da93a71dc7f47ba5b7018c61e4e1333 GIT binary patch literal 8279 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 ah;XrUg7WBEX&JC9aLV!;ay2sMq5lK1`$E?M literal 0 HcmV?d00001