diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index 247b9a3700..3744f76d98 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -17,6 +17,7 @@ import { baseNumbering } from './v2/exporter/helpers/base-list.definitions.js'; import { DEFAULT_CUSTOM_XML, DEFAULT_DOCX_DEFS } from './exporter-docx-defs.js'; import { getCommentDefinition, + isSyntheticTrackedChangeComment, prepareCommentParaIds, prepareCommentsXmlFilesForExport, } from './v2/exporter/commentsExporter.js'; @@ -1245,8 +1246,12 @@ class SuperConverter { // Reset export warnings for this export cycle this.exportWarnings = []; - // Filter out synthetic tracked change comments - they shouldn't be exported to comments.xml - const exportableComments = comments.filter((c) => !c.trackedChange); + // Exclude only SYNTHETIC tracked-change projection rows: the sidebar entries + // SuperDoc auto-generates for each tracked change carry `trackedChange: true` but have + // no authored body. Genuine user comments anchored to (or overlapping) a tracked change + // also carry `trackedChange: true`; those MUST still be exported, otherwise an + // accept-and-comment redline review loses every comment placed on a tracked change. + const exportableComments = comments.filter((c) => !isSyntheticTrackedChangeComment(c)); const commentsWithParaIds = exportableComments.map((c) => prepareCommentParaIds(c)); const commentDefinitions = commentsWithParaIds.map((c, index) => getCommentDefinition(c, index, commentsWithParaIds, editor), diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js index f9adf6f423..1e6b4d956c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.js @@ -3,6 +3,7 @@ import { carbonCopy } from '../../../utilities/carbonCopy.js'; import { COMMENT_REF, COMMENTS_XML_DEFINITIONS } from '../../exporter-docx-defs.js'; import { generateDocxRandomId } from '../../../helpers/generateDocxRandomId.js'; import { COMMENT_FILE_BASENAMES } from '../../constants.js'; +import { commentHasMeaningfulContent } from '../../../../utils/comment-content.js'; /** * Insert w15:paraId into the comments @@ -18,6 +19,34 @@ export const prepareCommentParaIds = (comment) => { return newComment; }; +/** + * Detect a SYNTHETIC tracked-change projection row that must NOT be written to any comment part + * (comments.xml, commentsExtended.xml, commentsIds.xml, commentsExtensible.xml). + * + * SuperDoc seeds the comments array with one auto-generated entry per tracked change so the + * sidebar can render it. A genuine user comment anchored to (or overlapping) a tracked change + * ALSO carries `trackedChange: true`, so the flag alone cannot discriminate. + * + * Primary discriminator: the explicit `isSyntheticTrackedChangeProjection` marker set where the + * row is created (comments-store `getPendingComment`). It is authoritative and immune to a + * synthetic row inheriting the sidebar draft text from the shared `currentCommentText` ref. + * + * Legacy fallback (rows authored before the marker existed): a tracked-change row with no + * authored body and no thread linkage. This MUST be value-based, not key-presence: at the export + * layer every record has `commentText`/`commentJSON`/`parentCommentId` keys stamped by + * `getValues()` and the export translators, so a key-presence test would treat every row as real. + * See `commentHasMeaningfulContent`. + * + * @param {Object} comment The export comment record + * @returns {boolean} True for synthetic projection rows that must be excluded from export + */ +export const isSyntheticTrackedChangeComment = (comment) => { + if (!comment) return false; + if (comment.isSyntheticTrackedChangeProjection === true) return true; + if (comment.trackedChange !== true) return false; + return !commentHasMeaningfulContent(comment) && !comment.parentCommentId && !comment.threadingParentCommentId; +}; + const getCommentIds = (comment) => { if (!comment) return []; return [comment.commentId, comment.importedId, comment.internalId].filter((id) => id != null).map((id) => String(id)); @@ -162,8 +191,17 @@ export const updateCommentsXml = (commentDefs = [], commentsXml) => { // Re-build the comment definitions commentDefs.forEach((commentDef) => { - const paragraphs = commentDef.elements || []; - if (!paragraphs.length) return; + let paragraphs = commentDef.elements || []; + if (!paragraphs.length) { + // A body-less comment that survived filtering (e.g. an empty reply) must still be a + // well-formed, anchored comment. Give it one empty paragraph so the normalization below + // always runs: it binds xmlns:custom, drops the non-schema w:done / w15:paraId from the + // element, and stamps w14:paraId on the paragraph (which the commentsExtended / + // commentsIds entries key on). This prevents emitting a bare whose custom: + // attributes have no in-scope namespace binding. + paragraphs = [{ type: 'element', name: 'w:p', attributes: {}, elements: [] }]; + commentDef.elements = paragraphs; + } const firstParagraph = paragraphs.find((node) => node?.name === 'w:p') ?? paragraphs[0]; const lastParagraph = @@ -277,15 +315,19 @@ export const updateCommentsExtendedXml = (comments = [], commentsExtendedXml, th 'w15:done': isResolved ? '1' : '0', }; - // Use paraIdParent only for comments that should use commentsExtended threading. - // Note: If the parent is a tracked change (not a real Word comment), we don't set this attribute - // because Word doesn't recognize tracked changes as comment parents. + // Thread a reply to its parent. `comments` has already had synthetic tracked-change + // projection rows removed upstream (isSyntheticTrackedChangeComment), so any parent found + // here is a real authored comment, including a genuine comment that happens to sit on + // tracked-changed text (which still carries trackedChange: true). We must NOT gate on + // parentComment.trackedChange: that dropped valid replies to comments anchored on a tracked + // change. If the parent was a synthetic projection it is already gone from `comments`, so + // findCommentById returns undefined and the reply correctly exports as a top-level comment + // (Word never threads a comment under a tracked change). const parentId = comment.threadingParentCommentId || comment.parentCommentId; const threadingStyle = resolveThreadingStyle(comment, profile); if (parentId && (threadingStyle === 'commentsExtended' || shouldIncludeForThreads)) { const parentComment = findCommentById(comments, parentId); - const allowTrackedParent = profile?.defaultStyle === 'commentsExtended'; - if (parentComment && (allowTrackedParent || !parentComment.trackedChange)) { + if (parentComment) { attributes['w15:paraIdParent'] = parentComment.commentParaId; } } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js index 3ead832580..8f0a40e8cd 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/commentsExporter.test.js @@ -1,13 +1,16 @@ import { getCommentDefinition, isCommentResolvedInThread, + isSyntheticTrackedChangeComment, updateCommentsExtendedXml, updateCommentsIdsAndExtensible, updateCommentsXml, prepareCommentsXmlFilesForExport, removeCommentsFilesFromConvertedXml, toIsoNoFractional, + prepareCommentParaIds, } from './commentsExporter.js'; +import { buildCommentJsonFromText } from '../../../../utils/comment-content.js'; // --- Shared fixtures --- @@ -649,6 +652,33 @@ describe('updateCommentsExtendedXml', () => { expect(childEntry.attributes['w15:paraIdParent']).toBe('PARENT-PARA'); }); + it('threads a reply to a real comment on tracked text under the word profile (no trackedChange gate)', () => { + // A genuine comment anchored to tracked-changed text still carries trackedChange:true. + // Synthetic projection rows are removed upstream, so any parent present here is real and a + // reply must thread to it. The previous `!parentComment.trackedChange` gate dropped this + // paraIdParent for every profile except commentsExtended. + const comments = [ + { + commentId: 'real-on-tc', + commentParaId: 'PARENT-PARA', + trackedChange: true, + commentText: 'Authored comment on a tracked insertion', + resolvedTime: null, + }, + { + commentId: 'reply-1', + commentParaId: 'REPLY-PARA', + parentCommentId: 'real-on-tc', + resolvedTime: null, + }, + ]; + + const result = updateCommentsExtendedXml(comments, { elements: [{ elements: [] }] }, 'word'); + const replyEntry = result.elements[0].elements.find((e) => e.attributes['w15:paraId'] === 'REPLY-PARA'); + + expect(replyEntry.attributes['w15:paraIdParent']).toBe('PARENT-PARA'); + }); + it('sets paraIdParent for range-based threads to preserve Word threading', () => { const comments = [ { @@ -811,4 +841,251 @@ describe('updateCommentsXml', () => { expect(updatedComment.attributes['w:email']).toBeUndefined(); expect(updatedComment.attributes['custom:email']).toBe('author@example.com'); }); + + it('hardens a body-less comment: injects a paragraph, strips w:done/w15:paraId, binds xmlns:custom', () => { + // The shape getCommentDefinition produces for an empty-body comment that survived filtering + // (e.g. an empty reply): no paragraphs, plus the non-schema w:done / w15:paraId and custom:* + // attributes with NO xmlns:custom declaration. The writer must normalize it rather than emit + // a bare whose custom: prefix is unbound. + const commentDef = { + type: 'element', + name: 'w:comment', + attributes: { + 'w:id': '0', + 'w:author': 'Author', + 'w:date': '2025-01-01T00:00:00Z', + 'w:initials': 'A', + 'w:done': '0', + 'w15:paraId': 'BODYLESS1', + 'custom:internalId': 'abc', + 'custom:trackedChange': true, + }, + elements: [], + }; + + const result = updateCommentsXml([commentDef], { elements: [{ elements: [] }] }); + const updatedComment = result.elements[0].elements[0]; + const paragraphs = updatedComment.elements.filter((n) => n.name === 'w:p'); + + // A paragraph was injected and carries the w14:paraId the extension parts key on. + expect(paragraphs.length).toBeGreaterThan(0); + expect(paragraphs[paragraphs.length - 1].attributes['w14:paraId']).toBe('BODYLESS1'); + // The custom: prefix is bound, so the part stays namespace-well-formed. + expect(updatedComment.attributes['xmlns:custom']).toBe( + 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + ); + // Non-schema attributes are dropped from the element. + expect(updatedComment.attributes['w:done']).toBeUndefined(); + expect(updatedComment.attributes['w15:paraId']).toBeUndefined(); + }); +}); + +describe('isSyntheticTrackedChangeComment', () => { + it('keeps a plain comment that is not linked to a tracked change', () => { + expect(isSyntheticTrackedChangeComment(makeComment())).toBe(false); + }); + + it('drops a body-less synthetic tracked-change projection row', () => { + expect(isSyntheticTrackedChangeComment({ commentId: 'tc-1', trackedChange: true })).toBe(true); + }); + + it('keeps a genuine user comment that is anchored to a tracked change', () => { + // The regression: these carry trackedChange:true but have an authored body, so they + // must still be exported — otherwise accept-and-comment redline reviews lose them. + const linkedComment = makeComment({ commentId: 'c-on-tc', trackedChange: true }); + expect(isSyntheticTrackedChangeComment(linkedComment)).toBe(false); + }); + + it.each(['commentText', 'text', 'commentJSON', 'elements'])( + 'treats a tracked-change comment carrying a "%s" body as a real comment', + (bodyKey) => { + expect(isSyntheticTrackedChangeComment({ commentId: 'c', trackedChange: true, [bodyKey]: 'x' })).toBe(false); + }, + ); + + it('keeps a tracked-change reply (has parentCommentId) even without its own body', () => { + expect(isSyntheticTrackedChangeComment({ commentId: 'r', trackedChange: true, parentCommentId: 'p' })).toBe(false); + }); + + it('keeps the comment whenever trackedChange is falsy or the input is nullish', () => { + expect(isSyntheticTrackedChangeComment({ commentId: 'c', trackedChange: false })).toBe(false); + expect(isSyntheticTrackedChangeComment({ commentId: 'c' })).toBe(false); + expect(isSyntheticTrackedChangeComment(null)).toBe(false); + }); + + // Regression: the shapes a comment ACTUALLY has at the export layer, after + // useComment.getValues() (stamps commentText:'' and parentCommentId) and the export + // translators (stamp commentJSON). A key-presence check returned false for all of these, + // so it leaked synthetic rows into comments.xml and the extension parts. + describe('explicit marker (primary discriminator)', () => { + it('drops a row carrying the isSyntheticTrackedChangeProjection marker', () => { + expect( + isSyntheticTrackedChangeComment({ commentId: 'tc-1', trackedChange: true, isSyntheticTrackedChangeProjection: true }), + ).toBe(true); + }); + + it('drops a marked row even if it inherited sidebar draft text (Claim 1 regression)', () => { + // A synthetic projection that picked up currentCommentText would look authored to a + // value-only check; the marker drops it regardless of the inherited body. + expect( + isSyntheticTrackedChangeComment({ + commentId: 'tc-1', + trackedChange: true, + isSyntheticTrackedChangeProjection: true, + commentText: 'leaked draft text', + commentJSON: buildCommentJsonFromText('leaked draft text'), + }), + ).toBe(true); + }); + + it('keeps a real comment, which is never marked, even on tracked text', () => { + expect( + isSyntheticTrackedChangeComment({ + commentId: 'real', + trackedChange: true, + commentText: 'Real comment', + isSyntheticTrackedChangeProjection: false, + }), + ).toBe(false); + }); + }); + + describe('normalized export shapes', () => { + it('drops a synthetic projection normalized via the comments store (commentJSON: [])', () => { + expect( + isSyntheticTrackedChangeComment({ + commentId: 'tc-1', + trackedChange: true, + commentText: '', + commentJSON: [], + parentCommentId: undefined, + trackedChangeText: 'inserted words', + }), + ).toBe(true); + }); + + it('drops a synthetic projection normalized via Editor.exportDocx (empty structured commentJSON)', () => { + expect( + isSyntheticTrackedChangeComment({ + commentId: 'tc-2', + trackedChange: true, + commentText: '', + commentJSON: buildCommentJsonFromText(''), + parentCommentId: undefined, + }), + ).toBe(true); + }); + + it('does not count the tracked change own text (trackedChangeText/deletedText) as a comment body', () => { + expect( + isSyntheticTrackedChangeComment({ + commentId: 'tc-3', + trackedChange: true, + commentText: '', + commentJSON: [], + trackedChangeText: 'was here', + deletedText: 'gone', + }), + ).toBe(true); + }); + + it('keeps a real comment on tracked text once normalized (authored commentJSON)', () => { + expect( + isSyntheticTrackedChangeComment({ + commentId: 'real-on-tc', + trackedChange: true, + commentText: 'Real comment', + commentJSON: buildCommentJsonFromText('Real comment'), + parentCommentId: undefined, + }), + ).toBe(false); + }); + + it('keeps a body-less reply once normalized (parentCommentId present)', () => { + expect( + isSyntheticTrackedChangeComment({ + commentId: 'reply-1', + trackedChange: true, + commentText: '', + commentJSON: [], + parentCommentId: 'real-on-tc', + }), + ).toBe(false); + }); + + it('keeps a body-less reply threaded via threadingParentCommentId', () => { + expect( + isSyntheticTrackedChangeComment({ + commentId: 'reply-2', + trackedChange: true, + commentText: '', + commentJSON: [], + threadingParentCommentId: 'real-on-tc', + }), + ).toBe(false); + }); + }); +}); + +// ============================================================================= +// Exporter-level F1/F2 oracle: synthetic rows must not reach any comment part +// ============================================================================= + +describe('synthetic tracked-change rows do not reach comment parts (F1/F2 oracle)', () => { + const threadingProfile = { + defaultStyle: 'commentsExtended', + mixed: false, + fileSet: { hasCommentsExtended: true, hasCommentsExtensible: true, hasCommentsIds: true }, + }; + + // F2: native Word emits zero comment parts for a tracked change with no authored comment. + it('emits no comment parts or relationships when the only rows are synthetic projections', () => { + const comments = [ + // Real production shape: carries the explicit marker (primary discriminator). + { commentId: 'tc-1', trackedChange: true, isSyntheticTrackedChangeProjection: true, commentText: '', commentJSON: [] }, + // Legacy shape (no marker): dropped by the value-based fallback. + { + commentId: 'tc-2', + trackedChange: true, + commentText: '', + commentJSON: buildCommentJsonFromText(''), + parentCommentId: undefined, + }, + ]; + const commentsWithParaIds = comments + .filter((c) => !isSyntheticTrackedChangeComment(c)) + .map((c) => prepareCommentParaIds(c)); + expect(commentsWithParaIds).toEqual([]); + + const result = prepareCommentsXmlFilesForExport({ + convertedXml: makeConvertedXml(), + defs: [], + commentsWithParaIds, + exportType: 'external', + threadingProfile, + }); + + expect(result.documentXml['word/comments.xml']).toBeUndefined(); + expect(result.documentXml['word/commentsExtended.xml']).toBeUndefined(); + expect(result.documentXml['word/commentsIds.xml']).toBeUndefined(); + expect(result.documentXml['word/commentsExtensible.xml']).toBeUndefined(); + expect(result.removedTargets).toHaveLength(4); + expect(result.relationships).toHaveLength(0); + }); + + // F1: a real comment anchored to tracked text survives; a co-located synthetic row does not. + it('keeps a real comment anchored to tracked text and drops the co-located synthetic row', () => { + const comments = [ + { + commentId: 'real-on-tc', + trackedChange: true, + commentText: 'Real comment', + commentJSON: buildCommentJsonFromText('Real comment'), + }, + { commentId: 'tc-1', trackedChange: true, commentText: '', commentJSON: [], parentCommentId: undefined }, + ]; + const exportable = comments.filter((c) => !isSyntheticTrackedChangeComment(c)); + expect(exportable).toHaveLength(1); + expect(exportable[0].commentId).toBe('real-on-tc'); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts index 099b8bbf85..95b7f48fb1 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts @@ -367,6 +367,28 @@ describe('syncCommentEntitiesFromCollaboration (SD-3214)', () => { expect(store[0].commentId).toBe('c-1'); }); + it('skips a marked synthetic projection even when it carries body/parent keys', () => { + // The comments store broadcasts synthetic rows through useComment.getValues(), which stamps + // commentText/parentCommentId keys (and the row may have picked up sidebar draft text). The + // key-presence heuristic would treat this as a real comment; the explicit + // isSyntheticTrackedChangeProjection marker must still skip it. + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [ + { + commentId: 'tc-1', + trackedChange: true, + isSyntheticTrackedChangeProjection: true, + commentText: 'leaked draft text', + trackedChangeText: 'inserted', + creatorName: 'A', + }, + { commentId: 'c-1', commentText: 'real comment', creatorName: 'B' }, + ]); + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0].commentId).toBe('c-1'); + }); + it('syncs linked tracked-content comments when they include comment payload', () => { const editor = makeEditorWithConverter(); syncCommentEntitiesFromCollaboration(editor, [ diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts index 3233fa25da..8a9ff98550 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts @@ -8,7 +8,7 @@ import type { TrackChangeType, } from '@superdoc/document-api'; export { buildCommentJsonFromText } from '../../utils/comment-content.js'; -import { buildCommentJsonFromText } from '../../utils/comment-content.js'; +import { buildCommentJsonFromText, collectCommentTextFragments } from '../../utils/comment-content.js'; const FALLBACK_STORE_KEY = '__documentApiComments'; const DELETED_COMMENT_SNAPSHOT_KEY = '__documentApiDeletedCommentSnapshots'; @@ -242,28 +242,6 @@ export function reconcileCommentEntityStoreWithAnchors( return { restored, removed }; } -function collectTextFragments(value: unknown, sink: string[]): void { - if (!value) return; - - if (typeof value === 'string') { - if (value.length > 0) sink.push(value); - return; - } - - if (Array.isArray(value)) { - for (const item of value) collectTextFragments(item, sink); - return; - } - - if (typeof value !== 'object') return; - const record = value as Record; - if (typeof record.text === 'string' && record.text.length > 0) sink.push(record.text); - - if (record.content) collectTextFragments(record.content, sink); - if (record.elements) collectTextFragments(record.elements, sink); - if (record.nodes) collectTextFragments(record.nodes, sink); -} - function hasOwnProperty(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } @@ -278,11 +256,18 @@ function hasCommentBodyPayload(raw: Record): boolean { } function isSyntheticTrackedChangeProjection(raw: Record): boolean { + // Honor the explicit marker first. The comments store flags auto-generated tracked-change + // projection rows with `isSyntheticTrackedChangeProjection`, and these reach collaboration + // through `useComment.getValues()`, which stamps `commentText`/`parentCommentId` keys. Without + // this check the key-presence fallback below would treat a marked row as a real comment entity + // and sync it into the store. + if (raw.isSyntheticTrackedChangeProjection === true) return true; + if (raw.trackedChange !== true) return false; - // Tracked-change projection rows share the comments Y.Array, but they are - // not user-authored comment entities. Linked user comments on tracked - // content also carry `trackedChange: true`, so only skip rows that lack any + // Legacy fallback for rows authored before the marker existed. Tracked-change projection rows + // share the comments Y.Array, but they are not user-authored comment entities. Linked user + // comments on tracked content also carry `trackedChange: true`, so only skip rows that lack any // actual comment body payload or thread metadata. return !hasCommentBodyPayload(raw) && !hasOwnProperty(raw, 'parentCommentId'); } @@ -291,8 +276,8 @@ export function extractCommentText(entry: CommentEntityRecord): string | undefin if (typeof entry.commentText === 'string') return entry.commentText; const fragments: string[] = []; - if (entry.commentJSON) collectTextFragments(entry.commentJSON, fragments); - if (entry.elements) collectTextFragments(entry.elements, fragments); + if (entry.commentJSON) collectCommentTextFragments(entry.commentJSON, fragments); + if (entry.elements) collectCommentTextFragments(entry.elements, fragments); if (!fragments.length) return undefined; return fragments.join('').trim(); diff --git a/packages/super-editor/src/editors/v1/utils/comment-content.ts b/packages/super-editor/src/editors/v1/utils/comment-content.ts index aee15f2cb9..be518e03b8 100644 --- a/packages/super-editor/src/editors/v1/utils/comment-content.ts +++ b/packages/super-editor/src/editors/v1/utils/comment-content.ts @@ -16,3 +16,58 @@ export function buildCommentJsonFromText(text: string): unknown[] { ], })); } + +/** + * Recursively collect non-empty text fragments from a comment body payload: a + * string, a PM/DOCX-schema node, or an array of either. Walks `content`, + * `elements`, and `nodes` the same way the comment exporter and entity store do. + */ +export function collectCommentTextFragments(value: unknown, sink: string[]): void { + if (!value) return; + + if (typeof value === 'string') { + if (value.length > 0) sink.push(value); + return; + } + + if (Array.isArray(value)) { + for (const item of value) collectCommentTextFragments(item, sink); + return; + } + + if (typeof value !== 'object') return; + const record = value as Record; + if (typeof record.text === 'string' && record.text.length > 0) sink.push(record.text); + + if (record.content) collectCommentTextFragments(record.content, sink); + if (record.elements) collectCommentTextFragments(record.elements, sink); + if (record.nodes) collectCommentTextFragments(record.nodes, sink); +} + +/** + * Value-based test for whether a comment record carries authored body content. + * + * By the time a comment reaches export it has been normalized: + * `useComment.getValues()` always stamps `commentText` (defaulting to '') and + * `parentCommentId`, and the export translators always stamp `commentJSON` + * (`[]` or `buildCommentJsonFromText('')`, i.e. a paragraph holding an empty run). + * A key-presence test therefore reports every row as having a body. This instead + * walks the actual payload and returns true only when there is non-whitespace + * authored text. + * + * `trackedChangeText` / `deletedText` are the tracked change's OWN text, not + * comment content, and are intentionally NOT consulted; otherwise every + * body-less tracked-change projection row would look authored. + */ +export function commentHasMeaningfulContent(comment: unknown): boolean { + if (!comment || typeof comment !== 'object') return false; + const record = comment as Record; + + const fragments: string[] = []; + collectCommentTextFragments(record.commentText, fragments); + collectCommentTextFragments(record.commentJSON, fragments); + collectCommentTextFragments(record.elements, fragments); + collectCommentTextFragments(record.text, fragments); + + return fragments.join('').trim().length > 0; +} diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment.js b/packages/superdoc/src/components/CommentsLayer/use-comment.js index d420e954a3..9a5809c59f 100644 --- a/packages/superdoc/src/components/CommentsLayer/use-comment.js +++ b/packages/superdoc/src/components/CommentsLayer/use-comment.js @@ -72,6 +72,10 @@ export default function useComment(params) { const threadingStyleOverride = params.threadingStyleOverride; const threadingParentCommentId = params.threadingParentCommentId; const originalXmlStructure = params.originalXmlStructure; + // Internal marker: set only on the auto-generated tracked-change projection rows the + // sidebar renders. It is the authoritative discriminator the exporter uses to drop these + // rows from comments.xml (and the extension parts); see isSyntheticTrackedChangeComment. + const isSyntheticTrackedChangeProjection = params.isSyntheticTrackedChangeProjection === true; const commentText = ref(params.commentText || ''); @@ -336,6 +340,7 @@ export default function useComment(params) { threadingStyleOverride, threadingParentCommentId, originalXmlStructure, + isSyntheticTrackedChangeProjection, }; }; @@ -380,6 +385,7 @@ export default function useComment(params) { threadingStyleOverride, threadingParentCommentId, originalXmlStructure, + isSyntheticTrackedChangeProjection, // Actions setText, diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index b5a7eb816f..55f866a144 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -776,6 +776,11 @@ export const useCommentsStore = defineStore('comments', () => { documentId, commentId: changeId, trackedChange: true, + // Mark this as the synthetic sidebar projection of a tracked change (not an authored + // comment), and force an empty body so it never inherits the sidebar draft text from + // the shared currentCommentText ref. The exporter drops rows carrying this marker. + isSyntheticTrackedChangeProjection: true, + commentText: '', trackedChangeText, trackedChangeType, trackedChangeDisplayType, @@ -1255,6 +1260,26 @@ export const useCommentsStore = defineStore('comments', () => { * @returns {void} */ const addComment = ({ superdoc, comment, skipEditorUpdate = false, broadcastChanges = true }) => { + // Synthetic tracked-change projection rows are sidebar projections of a tracked change, not + // authored comments. They must be added to the list (and broadcast) WITHOUT running the + // pending-comment lifecycle: no draft text, no pending selection source, no isInternal + // inheritance, and crucially no removePendingComment, which would wipe a draft the user is + // composing when a tracked change happens to sync. The marker keeps these rows out of export + // and entity sync downstream. + if (comment.isSyntheticTrackedChangeProjection) { + // The projection is created with isInternal:false and an empty body; preserve both. Do not + // inherit isInternal from the active comment and do not touch the pending-comment state. + const syntheticComment = useComment(comment.getValues()); + syntheticComment.setText({ text: '', suppressUpdate: true }); + commentsList.value.push(syntheticComment); + if (broadcastChanges) { + const event = { type: COMMENT_EVENTS.ADD, comment: syntheticComment.getValues() }; + syncCommentsToClients(superdoc, event); + superdoc.emit('comments-update', event); + } + return; + } + let parentComment = commentsList.value.find((c) => c.commentId === activeComment.value); if (!parentComment) parentComment = comment; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 0985405e7a..6e8095c729 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -1128,6 +1128,44 @@ describe('comments-store', () => { ); }); + it('adding a synthetic tracked-change projection does not pollute or clear a pending draft', () => { + const superdoc = { emit: vi.fn(), config: { isInternal: true } }; + + // The user is composing a comment: a pending comment with draft text is open. + store.commentsList = []; + store.pendingComment = { commentId: 'pending', selection: { source: 'floating' } }; + store.currentCommentText = 'My unsent draft'; + + // A tracked change syncs and projects a fresh sidebar row while the draft is open. + store.handleTrackedChangeUpdate({ + superdoc, + params: { + event: 'add', + changeId: 'tc-fresh', + trackedChangeText: 'inserted text', + trackedChangeType: 'insert', + deletedText: null, + authorEmail: 'user@example.com', + author: 'User', + date: 123, + importedAuthor: null, + documentId: 'doc-1', + coords: {}, + }, + }); + + // The projection is added as a body-less, marked synthetic row... + const projection = store.commentsList.find((c) => c.commentId === 'tc-fresh'); + expect(projection).toBeTruthy(); + expect(projection.isSyntheticTrackedChangeProjection).toBe(true); + expect(projection.commentText).toBe(''); + + // ...and the user's in-progress draft is untouched (no draft text copied, no + // removePendingComment side effect clearing the pending state). + expect(store.pendingComment).toEqual({ commentId: 'pending', selection: { source: 'floating' } }); + expect(store.currentCommentText).toBe('My unsent draft'); + }); + it('reopens resolved tracked change comments when add event dedupes an existing thread', () => { const superdoc = { emit: vi.fn(),