From bc8f6b6a4e2e68ab9cc59e8a4ff29a1e230761f5 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 11 May 2026 19:48:21 -0300 Subject: [PATCH 1/7] fix(super-editor): keep comment range markers as siblings of unpaired tracked changes (SD-2528) mergeConsecutiveTrackedChanges greedily absorbed trailing w:commentRangeEnd and w:r->w:commentReference elements into a w:del/w:ins wrapper even when no same-id wrapper followed to actually merge into. This produced a lopsided structure where w:commentRangeStart sat outside the wrapper but w:commentRangeEnd ended up inside it, breaking comment round-trip on redlined text. Buffer comment markers during the forward scan and only commit them inside the wrapper when a same-id merge actually happens. Otherwise emit them as siblings, matching Word's expected OOXML. SD-1519 merge behavior is unchanged and covered by the new tests. --- .../w/p/helpers/translate-paragraph-node.js | 53 +++++++------ .../helpers/translate-paragraph-node.test.js | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+), 25 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index 1825db4489..5e1c7bbef1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -6,6 +6,13 @@ import { generateParagraphProperties } from './generate-paragraph-properties.js' * Comment range markers between tracked changes with the same ID are included * inside the merged wrapper, matching Word's OOXML structure. * + * AIDEV-NOTE: Comment markers (w:commentRangeStart/End and w:r→w:commentReference) + * are only absorbed into the wrapper when a same-id merge actually happens. + * If a tracked change has no matching successor, trailing comment markers are + * preserved as siblings so the import side can re-pair the comment range with + * the wrapped text. Otherwise w:commentRangeEnd ends up inside w:del while + * w:commentRangeStart is outside it, breaking the round-trip (SD-2528). + * * See SD-1519 for details on the ECMA-376 spec compliance. * * @param {Array} elements The translated paragraph elements @@ -14,59 +21,55 @@ import { generateParagraphProperties } from './generate-paragraph-properties.js' function mergeConsecutiveTrackedChanges(elements) { if (!Array.isArray(elements) || elements.length === 0) return elements; + const isCommentMarker = (el) => { + if (!el) return false; + if (el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') return true; + if (el.name === 'w:r' && el.elements?.length === 1 && el.elements[0]?.name === 'w:commentReference') return true; + return false; + }; + const result = []; let i = 0; while (i < elements.length) { const current = elements[i]; - // Check if this is a tracked change wrapper (w:ins or w:del) if (current?.name === 'w:ins' || current?.name === 'w:del') { const tcId = current.attributes?.['w:id']; const tcName = current.name; - // Collect consecutive elements that belong to this tracked change const mergedElements = [...(current.elements || [])]; + const pendingComments = []; + let didMerge = false; let j = i + 1; while (j < elements.length) { const next = elements[j]; - // Include comment markers - they can sit inside tracked changes per ECMA-376 - if (next?.name === 'w:commentRangeStart' || next?.name === 'w:commentRangeEnd') { - mergedElements.push(next); + if (isCommentMarker(next)) { + pendingComments.push(next); j++; continue; } - // Include comment references (w:r containing w:commentReference) - if (next?.name === 'w:r') { - const hasOnlyCommentRef = next.elements?.length === 1 && next.elements[0]?.name === 'w:commentReference'; - if (hasOnlyCommentRef) { - mergedElements.push(next); - j++; - continue; - } - } - - // Merge with next tracked change if same type and ID if (next?.name === tcName && next.attributes?.['w:id'] === tcId) { - mergedElements.push(...(next.elements || [])); + mergedElements.push(...pendingComments, ...(next.elements || [])); + pendingComments.length = 0; + didMerge = true; j++; continue; } - // Stop merging when we hit a different element break; } - // Create the merged wrapper - result.push({ - name: tcName, - attributes: { ...current.attributes }, - elements: mergedElements, - }); - + if (didMerge) { + result.push({ name: tcName, attributes: { ...current.attributes }, elements: mergedElements }); + result.push(...pendingComments); + } else { + result.push(current); + result.push(...pendingComments); + } i = j; } else { result.push(current); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js index 4b40bb4f83..38b68f5526 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js @@ -82,4 +82,82 @@ describe('translateParagraphNode', () => { }); expect(generateParagraphProperties).toHaveBeenCalledWith(params); }); + + describe('mergeConsecutiveTrackedChanges (via translateParagraphNode)', () => { + const helloRun = { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Hell' }] }] }; + const commentRangeStart = { name: 'w:commentRangeStart', attributes: { 'w:id': '0' } }; + const commentRangeEnd = { name: 'w:commentRangeEnd', attributes: { 'w:id': '0' } }; + const commentReferenceRun = { + name: 'w:r', + elements: [{ name: 'w:commentReference', attributes: { 'w:id': '0' } }], + }; + const restRun = { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: ' rest' }] }] }; + + const buildDelWrapper = (id, innerText) => ({ + name: 'w:del', + attributes: { 'w:id': id, 'w:author': 'a', 'w:date': 'd' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:delText', elements: [{ type: 'text', text: innerText }] }] }], + }); + + it('keeps trailing comment markers as siblings when no same-id wrapper follows (SD-2528)', () => { + const params = baseParams(); + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([ + helloRun, + commentRangeStart, + buildDelWrapper('1', 'o worl'), + commentRangeEnd, + commentReferenceRun, + restRun, + ]); + + const result = translateParagraphNode(params); + const names = result.elements.map((e) => e.name); + + expect(names).toEqual(['w:r', 'w:commentRangeStart', 'w:del', 'w:commentRangeEnd', 'w:r', 'w:r']); + + const delNode = result.elements.find((e) => e.name === 'w:del'); + expect(delNode.elements.some((e) => e.name === 'w:commentRangeEnd')).toBe(false); + expect(delNode.elements.some((e) => e.name === 'w:commentReference')).toBe(false); + // The inner commentReference run check: delNode's children should be the original delText run only + expect(delNode.elements).toHaveLength(1); + }); + + it('merges two consecutive same-id tracked changes and absorbs comment markers between them (SD-1519)', () => { + const params = baseParams(); + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([ + buildDelWrapper('5', 'first'), + commentRangeEnd, + commentReferenceRun, + buildDelWrapper('5', 'second'), + ]); + + const result = translateParagraphNode(params); + + expect(result.elements).toHaveLength(1); + const delNode = result.elements[0]; + expect(delNode.name).toBe('w:del'); + const innerNames = delNode.elements.map((e) => e.name); + // first delText run, commentRangeEnd, commentReference run, second delText run + expect(innerNames).toEqual(['w:r', 'w:commentRangeEnd', 'w:r', 'w:r']); + }); + + it('does not merge wrappers with different ids and keeps comment markers between them as siblings', () => { + const params = baseParams(); + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([ + buildDelWrapper('1', 'first'), + commentRangeEnd, + buildDelWrapper('2', 'second'), + ]); + + const result = translateParagraphNode(params); + const names = result.elements.map((e) => e.name); + + expect(names).toEqual(['w:del', 'w:commentRangeEnd', 'w:del']); + expect(result.elements[0].elements).toHaveLength(1); + expect(result.elements[2].elements).toHaveLength(1); + }); + }); }); From 43fd0009bb57ba04fed5976187d990a63cfe685c Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 13 May 2026 16:38:21 -0300 Subject: [PATCH 2/7] fix(super-editor): fold commentRangeStart into TC wrapper for round-trip (SD-2528) The previous fix left commentRangeStart as a sibling of w:ins/w:del. documentCommentsImporter.js' extractCommentRangesFromDocument only associates a comment with a tracked change when commentRangeStart sits inside the wrapper, so the sibling shape silently dropped the comment to TC link on re-import. Fold any leading commentRangeStart sibling into the immediately following w:ins/w:del as its first child, matching the shape Word produces. The existing SD-1519 same-id merge for trailing commentRangeEnd and w:r/w:commentReference stays unchanged. Adds an end-to-end test that loads the Google-Docs TC+comment fixture, exports it, re-imports the exported XML, and asserts every comment that was originally inside a tracked change still carries trackedChangeParentId after the round-trip. --- .../w/p/helpers/translate-paragraph-node.js | 66 ++++++++++----- .../helpers/translate-paragraph-node.test.js | 36 +++++++-- .../sd-2528-tc-comment-roundtrip.test.js | 80 +++++++++++++++++++ 3 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index 5e1c7bbef1..ba6a766082 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -1,19 +1,52 @@ import { translateChildNodes } from '@converter/v2/exporter/helpers/index.js'; import { generateParagraphProperties } from './generate-paragraph-properties.js'; +const isTrackedChangeWrapper = (el) => el?.name === 'w:ins' || el?.name === 'w:del'; + +const isCommentMarker = (el) => { + if (!el) return false; + if (el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') return true; + if (el.name === 'w:r' && el.elements?.length === 1 && el.elements[0]?.name === 'w:commentReference') return true; + return false; +}; + +// AIDEV-NOTE: SD-2528. The importer associates a comment with a tracked change +// by walking document.xml and noting commentRangeStart elements that appear +// inside a w:ins/w:del wrapper (see documentCommentsImporter.js' +// extractCommentRangesFromDocument). Word always emits commentRangeStart inside +// the wrapper; emitting it as a sibling silently loses the comment ↔ TC link +// on re-import. +function foldLeadingCommentStartsIntoTrackedChanges(elements) { + const result = []; + let i = 0; + while (i < elements.length) { + if (elements[i]?.name !== 'w:commentRangeStart') { + result.push(elements[i]); + i++; + continue; + } + const leadingStarts = []; + while (i < elements.length && elements[i]?.name === 'w:commentRangeStart') { + leadingStarts.push(elements[i]); + i++; + } + const next = elements[i]; + if (isTrackedChangeWrapper(next)) { + result.push({ ...next, elements: [...leadingStarts, ...(next.elements || [])] }); + i++; + } else { + result.push(...leadingStarts); + } + } + return result; +} + /** - * Merge consecutive tracked change elements (w:ins/w:del) with the same ID. - * Comment range markers between tracked changes with the same ID are included - * inside the merged wrapper, matching Word's OOXML structure. - * - * AIDEV-NOTE: Comment markers (w:commentRangeStart/End and w:r→w:commentReference) - * are only absorbed into the wrapper when a same-id merge actually happens. - * If a tracked change has no matching successor, trailing comment markers are - * preserved as siblings so the import side can re-pair the comment range with - * the wrapped text. Otherwise w:commentRangeEnd ends up inside w:del while - * w:commentRangeStart is outside it, breaking the round-trip (SD-2528). - * - * See SD-1519 for details on the ECMA-376 spec compliance. + * Merge consecutive tracked change elements (w:ins/w:del) with the same ID, + * and fold any commentRangeStart that immediately precedes a tracked-change + * wrapper INTO the wrapper as its first child(ren). Trailing commentRangeEnd + * and w:r→w:commentReference stay as siblings and are only absorbed when a + * same-id successor wrapper triggers an SD-1519 merge. * * @param {Array} elements The translated paragraph elements * @returns {Array} Elements with consecutive tracked changes merged @@ -21,12 +54,7 @@ import { generateParagraphProperties } from './generate-paragraph-properties.js' function mergeConsecutiveTrackedChanges(elements) { if (!Array.isArray(elements) || elements.length === 0) return elements; - const isCommentMarker = (el) => { - if (!el) return false; - if (el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') return true; - if (el.name === 'w:r' && el.elements?.length === 1 && el.elements[0]?.name === 'w:commentReference') return true; - return false; - }; + elements = foldLeadingCommentStartsIntoTrackedChanges(elements); const result = []; let i = 0; @@ -34,7 +62,7 @@ function mergeConsecutiveTrackedChanges(elements) { while (i < elements.length) { const current = elements[i]; - if (current?.name === 'w:ins' || current?.name === 'w:del') { + if (isTrackedChangeWrapper(current)) { const tcId = current.attributes?.['w:id']; const tcName = current.name; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js index 38b68f5526..7121fb383c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js @@ -99,7 +99,7 @@ describe('translateParagraphNode', () => { elements: [{ name: 'w:r', elements: [{ name: 'w:delText', elements: [{ type: 'text', text: innerText }] }] }], }); - it('keeps trailing comment markers as siblings when no same-id wrapper follows (SD-2528)', () => { + it('folds a leading commentRangeStart into the following tracked-change wrapper (SD-2528)', () => { const params = baseParams(); generateParagraphProperties.mockReturnValue(null); translateChildNodes.mockReturnValue([ @@ -114,13 +114,37 @@ describe('translateParagraphNode', () => { const result = translateParagraphNode(params); const names = result.elements.map((e) => e.name); - expect(names).toEqual(['w:r', 'w:commentRangeStart', 'w:del', 'w:commentRangeEnd', 'w:r', 'w:r']); + expect(names).toEqual(['w:r', 'w:del', 'w:commentRangeEnd', 'w:r', 'w:r']); const delNode = result.elements.find((e) => e.name === 'w:del'); - expect(delNode.elements.some((e) => e.name === 'w:commentRangeEnd')).toBe(false); - expect(delNode.elements.some((e) => e.name === 'w:commentReference')).toBe(false); - // The inner commentReference run check: delNode's children should be the original delText run only - expect(delNode.elements).toHaveLength(1); + expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:r']); + expect(delNode.elements[0].attributes['w:id']).toBe('0'); + }); + + it('folds multiple consecutive commentRangeStart siblings into the following wrapper', () => { + const params = baseParams(); + generateParagraphProperties.mockReturnValue(null); + const startA = { name: 'w:commentRangeStart', attributes: { 'w:id': '0' } }; + const startB = { name: 'w:commentRangeStart', attributes: { 'w:id': '1' } }; + translateChildNodes.mockReturnValue([startA, startB, buildDelWrapper('7', 'x')]); + + const result = translateParagraphNode(params); + + expect(result.elements).toHaveLength(1); + const delNode = result.elements[0]; + expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:commentRangeStart', 'w:r']); + expect(delNode.elements.map((e) => e.attributes?.['w:id']).slice(0, 2)).toEqual(['0', '1']); + }); + + it('leaves a commentRangeStart as a sibling when it is not followed by a tracked-change wrapper', () => { + const params = baseParams(); + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([helloRun, commentRangeStart, restRun, commentRangeEnd, commentReferenceRun]); + + const result = translateParagraphNode(params); + const names = result.elements.map((e) => e.name); + + expect(names).toEqual(['w:r', 'w:commentRangeStart', 'w:r', 'w:commentRangeEnd', 'w:r']); }); it('merges two consecutive same-id tracked changes and absorbs comment markers between them (SD-1519)', () => { diff --git a/packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js new file mode 100644 index 0000000000..bb7fe65251 --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js @@ -0,0 +1,80 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; +import { carbonCopy } from '@core/utilities/carbonCopy.js'; +import { importCommentData } from '@converter/v2/importer/documentCommentsImporter.js'; + +// AIDEV-NOTE: SD-2528 — a comment attached to a tracked change must remain +// associated with that tracked change after a SuperDoc round-trip (export → +// re-import). The fixture used here is a real Google-Docs-originated DOCX that +// contains four TC+comment pairings (one delete, two inserts, one replace). +// The test asserts both halves of the association: +// 1. The exported document.xml places each `w:commentRangeStart` inside the +// `w:ins`/`w:del` wrapper that owns its anchored text — the only shape the +// importer's `extractCommentRangesFromDocument` walker recognises. +// 2. After re-importing the exported XML, every comment that was originally +// `trackedChange: true` still carries that flag. + +const getCommentJSONNodes = (comment) => { + if (Array.isArray(comment.elements) && comment.elements.length) { + return comment.elements; + } + return []; +}; + +const collectTrackedChangeIdsContainingCommentStarts = (documentXml) => { + const result = new Map(); + const walk = (elements, currentTcId) => { + if (!Array.isArray(elements)) return; + for (const el of elements) { + if (el?.name === 'w:commentRangeStart' && currentTcId != null) { + result.set(el.attributes?.['w:id'], currentTcId); + } + const isTc = el?.name === 'w:ins' || el?.name === 'w:del'; + const nextTcId = isTc ? (el.attributes?.['w:id'] ?? currentTcId) : currentTcId; + walk(el?.elements, nextTcId); + } + }; + walk(documentXml?.elements ?? []); + return result; +}; + +describe('SD-2528: tracked-change + comment round-trip', () => { + const filename = 'Google Docs Originated comments & TCs.docx'; + let docx; + let media; + let mediaFiles; + let fonts; + + beforeAll(async () => { + ({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(filename)); + }); + + it('preserves the comment ↔ tracked-change association after export and re-import', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const originalTcComments = editor.converter.comments.filter((c) => !!c.trackedChangeParentId); + expect(originalTcComments.length).toBeGreaterThan(0); + + const commentsForExport = editor.converter.comments.map((comment) => ({ + ...comment, + commentJSON: getCommentJSONNodes(comment), + })); + + await editor.exportDocx({ comments: commentsForExport, commentsType: 'external' }); + + const exportedXml = editor.converter.convertedXml; + const startsInsideTc = collectTrackedChangeIdsContainingCommentStarts(exportedXml['word/document.xml']); + expect(startsInsideTc.size).toBeGreaterThanOrEqual(originalTcComments.length); + + const exportedDocx = carbonCopy(exportedXml); + const reimportedComments = importCommentData({ docx: exportedDocx, converter: editor.converter }) ?? []; + expect(reimportedComments.length).toBe(editor.converter.comments.length); + + const tcCommentsAfterRoundTrip = reimportedComments.filter((c) => !!c.trackedChangeParentId); + expect(tcCommentsAfterRoundTrip.length).toBe(originalTcComments.length); + } finally { + editor.destroy(); + } + }); +}); From 1f023a6db67a441698ae67e9d0e5f7da8805b0de Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 13 May 2026 18:57:08 -0300 Subject: [PATCH 3/7] fix: cascade accept/reject of a tracked change to its anchored comments (SD-2528) A user comment anchored to a tracked change carries trackedChangeParentId pointing at the TC. Two bugs broke the link end-to-end: 1. docxImporter built two tracked-change id maps independently (trackedChangeIdMap and trackedChangeIdMapsByPart), each minting fresh UUIDs for the same Word w:id. The comments importer used the global map; ins/del translators used the per-part map. The two never matched, so trackedChangeParentId on a comment never pointed at the actual TC mark id in the PM doc. Fix: build the per-part maps first and reuse the document.xml entry as the global map. 2. The comments-store resolve handler only resolved the TC's own redline-display entry. User comments with trackedChangeParentId === the resolved TC stayed open. Fix: after resolving the TC entity, iterate commentsList and resolve every comment whose trackedChangeParentId matches. Defer via Promise.resolve so the cascading resolveComment doesn't dispatch into a still-running accept/rejectTrackedChangeById loop and collide with the loop's mutable tr. E2E browser repro on the real Google-Docs TC+comment fixture: accept TC by id, both the TC and its anchored user comments resolve in one user action. Same for reject. No mismatched-tr errors. The export-side round-trip test also asserts the two id maps are aligned and every comment trackedChangeParentId matches a real tracked-change mark id in the PM doc. --- .../v2/importer/docxImporter.js | 10 ++- .../sd-2528-tc-comment-roundtrip.test.js | 21 +++++ .../superdoc/src/stores/comments-store.js | 39 +++++++-- .../src/stores/comments-store.test.js | 85 +++++++++++++++++++ 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index 5a80b027d3..b4f84e5680 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -158,8 +158,16 @@ export const createDocumentJson = (docx, converter, editor) => { const trackedChangeIdMapOptions = { replacements: converter.trackedChangesOptions?.replacements ?? 'paired', }; - converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions); + // AIDEV-NOTE: SD-2528. The per-part map and the global map MUST share UUIDs + // for the same w:id, otherwise documentCommentsImporter (uses the global) + // and the ins/del translators (use the per-part) end up with two different + // UUIDs for the same tracked change — the comment's trackedChangeParentId + // never matches the tracked-change mark's id, breaking accept/reject + // cascading. converter.trackedChangeIdMapsByPart = buildTrackedChangeIdMapsByPart(docx, trackedChangeIdMapOptions); + converter.trackedChangeIdMap = + converter.trackedChangeIdMapsByPart.get('word/document.xml') ?? + buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering }); diff --git a/packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js index bb7fe65251..6fa00aa8f4 100644 --- a/packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/export/sd-2528-tc-comment-roundtrip.test.js @@ -56,6 +56,27 @@ describe('SD-2528: tracked-change + comment round-trip', () => { const originalTcComments = editor.converter.comments.filter((c) => !!c.trackedChangeParentId); expect(originalTcComments.length).toBeGreaterThan(0); + // The id space the comments importer uses (trackedChangeIdMap) must match + // the id space the ins/del translators use (trackedChangeIdMapsByPart). + // Otherwise the user comment's trackedChangeParentId never matches the + // tracked-change mark id in the PM doc and accept/reject can't cascade. + const bodyMap = editor.converter.trackedChangeIdMapsByPart?.get('word/document.xml'); + const globalMap = editor.converter.trackedChangeIdMap; + expect(bodyMap).toBeDefined(); + expect(globalMap).toBeDefined(); + expect([...globalMap.entries()]).toEqual([...bodyMap.entries()]); + + const tcMarkIds = new Set(); + editor.state.doc.descendants((node) => { + if (!node.isInline) return; + for (const m of node.marks || []) { + if (m.type.name === 'trackInsert' || m.type.name === 'trackDelete') tcMarkIds.add(m.attrs?.id); + } + }); + originalTcComments.forEach((comment) => { + expect(tcMarkIds.has(comment.trackedChangeParentId)).toBe(true); + }); + const commentsForExport = editor.converter.comments.map((comment) => ({ ...comment, commentJSON: getCommentJSONNodes(comment), diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index bfcba4c3ca..45ac8c1d00 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -674,15 +674,42 @@ export const useCommentsStore = defineStore('comments', () => { emitTrackedChangeEvent(emitData); } else if (event === 'resolve') { const existingTrackedChange = findTrackedChangeById(); - if (!existingTrackedChange || existingTrackedChange.resolvedTime) return; - - // Selection/toolbar reject emits tracked-change resolve events. Use the same - // resolution path as the comment dialog so one method owns state + sync + emit. - existingTrackedChange.resolveComment({ + const resolveArgs = { email: params.resolvedByEmail ?? superdoc?.user?.email ?? null, name: params.resolvedByName ?? superdoc?.user?.name ?? null, superdoc, - }); + }; + + if (existingTrackedChange && !existingTrackedChange.resolvedTime) { + // Selection/toolbar reject emits tracked-change resolve events. Use the same + // resolution path as the comment dialog so one method owns state + sync + emit. + existingTrackedChange.resolveComment(resolveArgs); + } + + // AIDEV-NOTE: SD-2528. User-attached comments on a tracked change carry + // trackedChangeParentId === . When the TC is accepted + // or rejected, those comment bubbles must also resolve — otherwise the + // comment lingers after the redline it referred to is gone. Defer to a + // microtask so the cascading resolveComment doesn't dispatch into a + // still-running acceptTrackedChangeById/rejectTrackedChangeById loop and + // collide with its mutable `tr`. + if (normalizedChangeId) { + const linkedToResolve = commentsList.value.filter((linkedComment) => { + if (!linkedComment || linkedComment === existingTrackedChange) return false; + if (linkedComment.resolvedTime) return false; + const linkedParentId = + linkedComment.trackedChangeParentId != null ? String(linkedComment.trackedChangeParentId) : null; + return linkedParentId === normalizedChangeId; + }); + if (linkedToResolve.length) { + Promise.resolve().then(() => { + linkedToResolve.forEach((linkedComment) => { + if (linkedComment.resolvedTime) return; + linkedComment.resolveComment(resolveArgs); + }); + }); + } + } } }; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 8d8fbd7499..3cf68493f8 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -649,6 +649,91 @@ describe('comments-store', () => { ); }); + it('cascades resolve to user comments anchored to the same tracked change (SD-2528)', async () => { + const superdoc = { + emit: vi.fn(), + user: { email: 'reviewer@example.com', name: 'Reviewer' }, + }; + + const trackedChangeComment = { + commentId: 'tc-1', + trackedChange: true, + resolvedTime: null, + getValues: vi.fn(() => ({ commentId: 'tc-1' })), + resolveComment: vi.fn(function () { + this.resolvedTime = Date.now(); + }), + }; + + const linkedUserComment = { + commentId: 'user-comment-1', + trackedChange: false, + trackedChangeParentId: 'tc-1', + resolvedTime: null, + getValues: vi.fn(() => ({ commentId: 'user-comment-1' })), + resolveComment: vi.fn(function () { + this.resolvedTime = Date.now(); + }), + }; + + const unrelatedUserComment = { + commentId: 'user-comment-2', + trackedChange: false, + trackedChangeParentId: 'tc-99', + resolvedTime: null, + getValues: vi.fn(() => ({ commentId: 'user-comment-2' })), + resolveComment: vi.fn(), + }; + + store.commentsList = [trackedChangeComment, linkedUserComment, unrelatedUserComment]; + + store.handleTrackedChangeUpdate({ + superdoc, + params: { event: 'resolve', changeId: 'tc-1' }, + }); + + expect(trackedChangeComment.resolveComment).toHaveBeenCalledTimes(1); + // Cascading runs in a microtask so we wait one turn before asserting. + await Promise.resolve(); + expect(linkedUserComment.resolveComment).toHaveBeenCalledTimes(1); + expect(linkedUserComment.resolveComment).toHaveBeenCalledWith({ + email: 'reviewer@example.com', + name: 'Reviewer', + superdoc, + }); + expect(unrelatedUserComment.resolveComment).not.toHaveBeenCalled(); + }); + + it('does not re-resolve already-resolved linked user comments', async () => { + const superdoc = { emit: vi.fn(), user: { email: 'a@a', name: 'A' } }; + + const trackedChangeComment = { + commentId: 'tc-2', + trackedChange: true, + resolvedTime: null, + getValues: vi.fn(() => ({})), + resolveComment: vi.fn(function () { + this.resolvedTime = Date.now(); + }), + }; + + const alreadyResolvedLinked = { + commentId: 'user-2', + trackedChange: false, + trackedChangeParentId: 'tc-2', + resolvedTime: 1234, + getValues: vi.fn(() => ({})), + resolveComment: vi.fn(), + }; + + store.commentsList = [trackedChangeComment, alreadyResolvedLinked]; + + store.handleTrackedChangeUpdate({ superdoc, params: { event: 'resolve', changeId: 'tc-2' } }); + + await Promise.resolve(); + expect(alreadyResolvedLinked.resolveComment).not.toHaveBeenCalled(); + }); + it('syncs and emits an update when add event dedupes an existing tracked change', () => { const superdoc = { emit: vi.fn(), From 0f24fafa40cf9c9be18b9ada899486adc02b7829 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 13 May 2026 19:21:23 -0300 Subject: [PATCH 4/7] fix(super-editor): thread reply-to-TC under its tracked-change bubble on re-import (SD-2528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply that the user typed under a tracked-change bubble has parentCommentId pointing at the synthetic TC entity in the comments store. On export the TC parent is filtered out of comments.xml (TC entries are not real comments), so the reply lands in the file without any paraIdParent. On re-import the reply gets trackedChangeParentId via the document.xml walker (the commentRange wraps the TC text) but parentCommentId was left undefined — the sidebar then renders the reply as a separate top-level bubble next to the TC instead of nested under it, matching the user-reported regression in image 1 of SD-2528. Promote trackedChangeParentId to parentCommentId when no explicit parent is set. CommentDialog already threads via direct parentCommentId === trackedChangeId (line 321), so this is the cheapest path to restore the live pre-export state. Round-trip stable: re-export still filters TC parents but re-emits the commentRange inside the wrapper, which gets re-detected on the next import via extractCommentRangesFromDocument and re-establishes the linkage. --- .../v2/importer/documentCommentsImporter.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js index 8a08844ebc..1060a53ccf 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js @@ -208,9 +208,6 @@ const generateCommentsWithExtendedData = ({ docx, comments, converter, threading } } - // Track the tracked change association but don't use it as parentCommentId - // This keeps comments and tracked changes as separate bubbles in the UI - // while preserving the relationship for export and visual purposes const trackedChangeParentId = isInsideTrackedChange ? trackedChangeParent.trackedChangeId : undefined; // Only use range-based parenting as fallback when: @@ -225,6 +222,21 @@ const generateCommentsWithExtendedData = ({ docx, comments, converter, threading } } + // AIDEV-NOTE: SD-2528. A reply that user typed under a tracked-change bubble + // is exported with its commentRange wrapping the TC's text but no + // paraIdParent (the parent is a synthetic TC entity, filtered from + // comments.xml). On re-import the only signal we have is + // trackedChangeParentId. We promote that to parentCommentId so the comments + // sidebar collapses the reply back into the TC bubble — matching the live + // pre-export state. The CommentDialog already threads via direct + // parentCommentId === trackedChangeId on line 321, so this is the cheapest + // restore path. Round-trip stable: re-export filters TC parents but + // re-emits the commentRange inside the wrapper, which gets re-detected on + // the next import. + if (!parentCommentId && trackedChangeParentId) { + parentCommentId = trackedChangeParentId; + } + return { ...comment, isDone, From 119c6a1e862ce7ad6b75bd57cf1013a62d4eba94 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 13 May 2026 19:48:57 -0300 Subject: [PATCH 5/7] fix(comments): thread tracked-change replies regardless of file origin (SD-2528) The UI guarded TC reply threading with isRangeThreadedComment, which is true only when the source DOCX has no commentsExtended.xml (Google Docs style). SuperDoc-exported DOCX files always write commentsExtended.xml, so on re-import the guard short-circuited and the reply rendered as a top-level bubble next to its TC instead of nested under it. Drop the file-origin guard from the two sites that threaded TC replies: collectTrackedChangeThread in CommentDialog.vue and shouldThreadWithTrackedChange in comments-store.js. trackedChangeParentId pointing at a tracked-change entity is sufficient to thread; file origin should not change whether a comment threads under its TC. Reverts the earlier importer-side patch that promoted trackedChangeParentId into parentCommentId. That patch violated the comment-diffing contract (parentCommentId is diffed; trackedChangeParentId is intentionally ignored because it is regenerated across imports) and broke six existing tests. The UI-side change is surgical and breaks no tests. --- .../v2/importer/documentCommentsImporter.js | 15 --------------- .../components/CommentsLayer/CommentDialog.vue | 12 ++++++++---- packages/superdoc/src/stores/comments-store.js | 13 +++---------- 3 files changed, 11 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js index 1060a53ccf..35827ba02d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js @@ -222,21 +222,6 @@ const generateCommentsWithExtendedData = ({ docx, comments, converter, threading } } - // AIDEV-NOTE: SD-2528. A reply that user typed under a tracked-change bubble - // is exported with its commentRange wrapping the TC's text but no - // paraIdParent (the parent is a synthetic TC entity, filtered from - // comments.xml). On re-import the only signal we have is - // trackedChangeParentId. We promote that to parentCommentId so the comments - // sidebar collapses the reply back into the TC bubble — matching the live - // pre-export state. The CommentDialog already threads via direct - // parentCommentId === trackedChangeId on line 321, so this is the cheapest - // restore path. Round-trip stable: re-export filters TC parents but - // re-emits the commentRange inside the wrapper, which gets re-detected on - // the next import. - if (!parentCommentId && trackedChangeParentId) { - parentCommentId = trackedChangeParentId; - } - return { ...comment, isDone, diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 74a5e4d922..93fe0b80c5 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -319,10 +319,14 @@ const collectTrackedChangeThread = (parentComment, allComments) => { allComments.forEach((comment) => { if (comment.commentId === trackedChangeId) return; const isDirectChild = comment.parentCommentId === trackedChangeId; - const isRangeBasedTrackedChangeComment = - comment.trackedChangeParentId === trackedChangeId && isRangeThreadedComment(comment); - - if (isDirectChild || isRangeBasedTrackedChangeComment) { + // SD-2528: a comment anchored on the tracked change must thread under it + // regardless of file origin. The previous `&& isRangeThreadedComment` guard + // only let Google-Docs-style files (no commentsExtended.xml) thread, so + // SuperDoc-exported docs lost the linkage even when the importer set + // trackedChangeParentId correctly. + const isTrackedChangeAnchoredComment = comment.trackedChangeParentId === trackedChangeId; + + if (isDirectChild || isTrackedChangeAnchoredComment) { threadIds.add(comment.commentId); queue.push(comment.commentId); } diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 45ac8c1d00..9e06eafe95 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -116,18 +116,11 @@ export const useCommentsStore = defineStore('comments', () => { return getComment(comment.parentCommentId); }; - const isRangeThreadedComment = (comment) => { - if (!comment) return false; - return ( - comment.threadingStyleOverride === 'range-based' || - comment.threadingMethod === 'range-based' || - comment.originalXmlStructure?.hasCommentsExtended === false - ); - }; - + // SD-2528: a comment anchored on a tracked change must thread under that TC + // regardless of file origin. The previous range-threaded-only guard was + // Google-Docs-only and broke SuperDoc-exported documents on re-import. const shouldThreadWithTrackedChange = (comment) => { if (!comment?.trackedChangeParentId) return false; - if (!isRangeThreadedComment(comment)) return false; const trackedChange = getComment(comment.trackedChangeParentId); return Boolean(trackedChange?.trackedChange); }; From aa88a58416a26c38da991edd9b03d37a31fb65d6 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 14 May 2026 08:13:59 -0300 Subject: [PATCH 6/7] fix(comments): preserve TC color on anchored comments + clean up IMPORTED/resolve gates (SD-2528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three visual round-trip regressions after the SD-2528 fix made TC replies thread again: 1. CommentHighlightDecorator painted its pink (external) / green (internal) inline background on every element with the superdoc-comment-highlight class — including text that already carries a track-insert-dec / track-delete-dec decoration. The inline style won over the TC's own CSS class background, so a green trackInsert came back pink after re-import. Skip the BG override when the element is also a tracked-change decoration: the TC color (green for insert, red for delete) is the right signal for the user, and the comment range is still visually identified by its dashed border + sidebar bubble. 2. CommentHeader's IMPORTED tag fired whenever comment.origin or importedAuthor was set — including comments authored by the current user in a previous session. Round-tripping a file you exported then re-opened should not relabel your own comments as imported. Suppress the tag when the comment's creatorEmail matches the current user's email. 3. CommentHeader's allowResolve guard treated parentCommentId as the only marker of a child comment. A TC-anchored reply on re-import keeps the linkage through trackedChangeParentId only (parentCommentId is left undefined to preserve the comment-diffing contract). The resolve check affordance therefore appeared on re-imported replies even though the pre-export state had no parentCommentId either. Treat trackedChangeParentId as an equivalent child signal. All three are surgical render-side gates — no converter / data-model changes. 1369 super-editor presentation tests pass. --- .../dom/CommentHighlightDecorator.ts | 32 +++++++++++++++++-- .../CommentsLayer/CommentHeader.vue | 15 +++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/CommentHighlightDecorator.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/CommentHighlightDecorator.ts index 3cc87e3e07..1991888271 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/CommentHighlightDecorator.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/CommentHighlightDecorator.ts @@ -171,9 +171,27 @@ export class CommentHighlightDecorator { // Determine if primary (first) comment is internal — used for uniform/faded colors. const primaryIsInternal = internalIds.has(ids[0]); + // SD-2528: a comment anchored on tracked-change text already shows the + // TC's own background (green for trackInsert, red for trackDelete via + // `.track-insert-dec.highlighted` / `.track-delete-dec.highlighted`). The + // comment highlight stacking on top of that paints pink/green over the + // TC color, making an "insert" look pink after re-import. Leave the + // background alone when the element is also a TC decoration so the TC + // color wins (matches Word — comments anchored on a redline don't recolor + // the redline). Hover/focus affordances still come from the TC's own + // `.track-change-focused` class. + const isTrackedChangeAnchored = + el.classList.contains('track-insert-dec') || + el.classList.contains('track-delete-dec') || + el.classList.contains('track-format-dec'); + if (activeId == null) { // No active comment → uniform light highlight - applyBgColor(el, primaryIsInternal ? H.INT : H.EXT); + if (!isTrackedChangeAnchored) { + applyBgColor(el, primaryIsInternal ? H.INT : H.EXT); + } else { + el.style.backgroundColor = ''; + } el.style.boxShadow = ''; continue; } @@ -184,7 +202,11 @@ export class CommentHighlightDecorator { if (matchedId != null) { // This element belongs to the active comment → bright highlight const matchIsInternal = internalIds.has(matchedId); - applyBgColor(el, matchIsInternal ? H.INT_ACTIVE : H.EXT_ACTIVE); + if (!isTrackedChangeAnchored) { + applyBgColor(el, matchIsInternal ? H.INT_ACTIVE : H.EXT_ACTIVE); + } else { + el.style.backgroundColor = ''; + } // Nested comments: other IDs besides the active one const hasNested = ids.length > 1; @@ -195,7 +217,11 @@ export class CommentHighlightDecorator { } } else { // Active comment is set but doesn't match this element → faded - applyBgColor(el, primaryIsInternal ? H.INT_FADED : H.EXT_FADED); + if (!isTrackedChangeAnchored) { + applyBgColor(el, primaryIsInternal ? H.INT_FADED : H.EXT_FADED); + } else { + el.style.backgroundColor = ''; + } el.style.boxShadow = ''; } } diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue index ac6be1e6e5..40aab0b779 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue @@ -57,8 +57,12 @@ const generallyAllowed = computed(() => { const allowResolve = computed(() => { if (!generallyAllowed.value) return false; - // Do not allow child comments to resolve + // Do not allow child comments to resolve. A reply anchored to a tracked + // change keeps the linkage via trackedChangeParentId (no parentCommentId), + // so treat it as a child too — otherwise re-imported TC replies render an + // extra resolve affordance that the live pre-export state doesn't (SD-2528). if (props.comment.parentCommentId) return false; + if (props.comment.trackedChangeParentId) return false; const context = { comment: props.comment, @@ -134,8 +138,15 @@ const handleSelect = (value) => emit('overflow-select', value); // Imported comments have `origin` set (e.g. 'word'); imported tracked changes // don't carry `origin` but do carry `importedAuthor` from the mark attributes. +// SD-2528: suppress the IMPORTED tag when the current user is the author — +// re-opening your own exported file shouldn't relabel your own comments as +// "imported"; that visual churn is what made round-tripping look broken. const isImported = computed(() => { - return props.comment.origin != null || !!props.comment.importedAuthor?.name; + const hasImportOrigin = props.comment.origin != null || !!props.comment.importedAuthor?.name; + if (!hasImportOrigin) return false; + const currentUserEmail = proxy.$superdoc.config.user?.email; + if (currentUserEmail && props.comment.creatorEmail === currentUserEmail) return false; + return true; }); const getCurrentUser = computed(() => { From 2a04dc9adb4308e4be7106aa221a28e84689cfc2 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 14 May 2026 14:42:41 -0300 Subject: [PATCH 7/7] fix(comments): scope cascade to active doc + tighten TC-anchored gates (SD-2528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex's 3 P2 review findings from PR #3239: P2 #1 — comments-store.js cascade scope The new cascade-resolve scan introduced in aa88a5841 didn't honour the resolve event's documentId. findTrackedChangeById (line 591) correctly scopes its match by belongsToTrackedChangeSyncDocument; the cascade six lines lower did not. In multi-document sessions where imported tracked-change ids collide across files (each w:id space is local), accepting a change in document A would also resolve comments anchored on the same id in document B. Mirror the same per-document filter when a documentId is provided; single-document callers (no documentId on the event) keep the legacy global behaviour. P2 #2 — CommentHighlightDecorator.ts visual gate The earlier suppression triggered on any of `track-insert-dec`, `track-delete-dec`, or `track-format-dec`. Per layout-engine styles.ts only `.track-insert-dec.highlighted` and `.track-delete-dec.highlighted` paint a background; `.track-format-dec` only paints a border-bottom, and the `.highlighted` modifier is only applied in "review" / All Markup mode (renderer.ts:909-928). In Original / Final modes, and on format-only changes, the suppression cleared the comment fill with nothing to replace it, making the bubble invisible. Tighten the gate to require both `.highlighted` and one of the bg-painting base classes. P2 #3 — collectTrackedChangeThread parent shadowing documentCommentsImporter can produce a comment with BOTH a non-TC `parentCommentId` and a `trackedChangeParentId`: the comment's range lives inside a TC, but its conversational thread starts at a regular comment outside the TC. The previous unconditional pull on trackedChangeParentId placed such replies in both threads. Restrict the direct seed to roots (no parentCommentId) and let the BFS step pick up same-TC-anchored chains via parent links. Extract the helper to a sibling module so the BFS logic can be unit-tested in isolation — previously trapped inside CommentDialog.vue's