diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 4652c1570..bfbaa006e 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -138,6 +138,9 @@ export interface SaveOptions { /** Highlight color for fields */ fieldsHighlightColor?: string | null; + + /** When true (default), passing an empty comments array preserves existing comments. When false, empty array removes all comments. */ + preserveCommentsOnEmpty?: boolean; } /** @@ -2445,6 +2448,7 @@ export class Editor extends EventEmitter { comments, getUpdatedDocs = false, fieldsHighlightColor = null, + preserveCommentsOnEmpty = true, }: { isFinalDoc?: boolean; commentsType?: string; @@ -2453,6 +2457,7 @@ export class Editor extends EventEmitter { comments?: Comment[]; getUpdatedDocs?: boolean; fieldsHighlightColor?: string | null; + preserveCommentsOnEmpty?: boolean; } = {}): Promise | ProseMirrorJSON | string | undefined> { try { // Use provided comments, or fall back to imported comments from converter @@ -2479,6 +2484,7 @@ export class Editor extends EventEmitter { this, exportJsonOnly, fieldsHighlightColor, + preserveCommentsOnEmpty, ); this.#validateDocumentExport(); @@ -2537,8 +2543,10 @@ export class Editor extends EventEmitter { updatedDocs['word/_rels/footnotes.xml.rels'] = String(footnotesRelsXml); } - if (preparedComments.length) { - const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml'].elements[0]); + // Check if comment files exist in convertedXml (they're removed when cleaning or empty array) + const commentsFile = this.converter.convertedXml['word/comments.xml']; + if (commentsFile?.elements?.[0]) { + const commentsXml = this.converter.schemaToXml(commentsFile.elements[0]); updatedDocs['word/comments.xml'] = String(commentsXml); const commentsExtended = this.converter.convertedXml['word/commentsExtended.xml']; @@ -2851,6 +2859,7 @@ export class Editor extends EventEmitter { commentsType: options?.commentsType, comments: options?.comments, fieldsHighlightColor: options?.fieldsHighlightColor, + preserveCommentsOnEmpty: options?.preserveCommentsOnEmpty, }); return result as Blob | Buffer; diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index b8f1b7491..1dca21d17 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -971,6 +971,7 @@ class SuperConverter { editor, exportJsonOnly = false, fieldsHighlightColor, + preserveCommentsOnEmpty = true, ) { // Filter out synthetic tracked change comments - they shouldn't be exported to comments.xml const exportableComments = comments.filter((c) => !c.trackedChange); @@ -1019,20 +1020,34 @@ class SuperConverter { ); // Update content types and comments files as needed + // Empty array preserves existing comments unless preserveCommentsOnEmpty is true let updatedXml = { ...this.convertedXml }; let commentsRels = []; - if (comments.length) { - const { documentXml, relationships } = this.#prepareCommentsXmlFilesForExport({ - defs: params.exportedCommentDefs, - exportType: commentsExportType, - commentsWithParaIds, - }); - updatedXml = { ...documentXml }; - commentsRels = relationships; - } + const { documentXml, relationships } = this.#prepareCommentsXmlFilesForExport({ + defs: params.exportedCommentDefs, + exportType: commentsExportType, + commentsWithParaIds, + preserveCommentsOnEmpty, + }); + updatedXml = { ...documentXml }; + commentsRels = relationships; this.convertedXml = { ...this.convertedXml, ...updatedXml }; + // Explicitly delete comment files when exporting with no comments + // (spread merge doesn't remove keys that exist in the original object) + const shouldRemoveComments = + commentsExportType === 'clean' || (commentsWithParaIds.length === 0 && preserveCommentsOnEmpty === false); + if (shouldRemoveComments) { + const commentFileKeys = [ + 'word/comments.xml', + 'word/commentsExtended.xml', + 'word/commentsExtensible.xml', + 'word/commentsIds.xml', + ]; + commentFileKeys.forEach((key) => delete this.convertedXml[key]); + } + const headFootRels = this.#exportProcessHeadersFooters({ isFinalDoc }); // Update the rels table @@ -1111,13 +1126,14 @@ class SuperConverter { /** * Update comments files and relationships depending on export type */ - #prepareCommentsXmlFilesForExport({ defs, exportType, commentsWithParaIds }) { + #prepareCommentsXmlFilesForExport({ defs, exportType, commentsWithParaIds, preserveCommentsOnEmpty }) { const { documentXml, relationships } = prepareCommentsXmlFilesForExport({ exportType, convertedXml: this.convertedXml, defs, commentsWithParaIds, threadingProfile: this.commentThreadingProfile, + preserveCommentsOnEmpty, }); return { documentXml, relationships }; diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js b/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js index 8758b491a..90db332dd 100644 --- a/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js +++ b/packages/super-editor/src/core/super-converter/v2/exporter/commentsExporter.js @@ -364,14 +364,23 @@ export const prepareCommentsXmlFilesForExport = ({ commentsWithParaIds, exportType, threadingProfile, + preserveCommentsOnEmpty, }) => { const relationships = []; - if (exportType === 'clean') { + // Remove comment files if explicitly cleaning OR if no comments and preserveCommentsOnEmpty is false + const shouldRemoveComments = + exportType === 'clean' || (commentsWithParaIds.length === 0 && preserveCommentsOnEmpty === false); + if (shouldRemoveComments) { const documentXml = removeCommentsFilesFromConvertedXml(convertedXml); return { documentXml, relationships }; } + // Preserve existing comments when array is empty and preserveCommentsOnEmpty is true (default) + if (commentsWithParaIds.length === 0) { + return { documentXml: convertedXml, relationships }; + } + const exportStrategy = determineExportStrategy(commentsWithParaIds); const updatedXml = generateConvertedXmlWithCommentFiles(convertedXml, threadingProfile?.fileSet); diff --git a/packages/super-editor/src/tests/export/commentsRoundTrip.test.js b/packages/super-editor/src/tests/export/commentsRoundTrip.test.js index 32c3f4432..a7e601a1b 100644 --- a/packages/super-editor/src/tests/export/commentsRoundTrip.test.js +++ b/packages/super-editor/src/tests/export/commentsRoundTrip.test.js @@ -367,6 +367,120 @@ describe('Resolved comments round-trip', () => { }); }); +describe('preserveCommentsOnEmpty flag behavior', () => { + const filename = 'WordOriginatedComments.docx'; + let docx; + let media; + let mediaFiles; + let fonts; + + beforeAll(async () => { + ({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(filename)); + }); + + it('preserves existing comments when empty array passed and preserveCommentsOnEmpty is true', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const originalCommentCount = editor.converter.comments.length; + expect(originalCommentCount).toBeGreaterThan(0); + + await editor.exportDocx({ + comments: [], + commentsType: 'external', + preserveCommentsOnEmpty: true, + }); + + const exportedXml = editor.converter.convertedXml; + const commentsXml = exportedXml['word/comments.xml']; + + // Comments should be preserved (not removed) + expect(commentsXml).toBeDefined(); + } finally { + editor.destroy(); + } + }); + + it('preserves existing comments when empty array passed and preserveCommentsOnEmpty is omitted (default true)', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const originalCommentCount = editor.converter.comments.length; + expect(originalCommentCount).toBeGreaterThan(0); + + // Omit preserveCommentsOnEmpty entirely - should default to true (preserving) + await editor.exportDocx({ + comments: [], + commentsType: 'external', + }); + + const exportedXml = editor.converter.convertedXml; + const commentsXml = exportedXml['word/comments.xml']; + + // Comments should be preserved (not removed) - backward compatible behavior + expect(commentsXml).toBeDefined(); + } finally { + editor.destroy(); + } + }); + + it('removes all comments when empty array passed and preserveCommentsOnEmpty is false', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const originalCommentCount = editor.converter.comments.length; + expect(originalCommentCount).toBeGreaterThan(0); + + await editor.exportDocx({ + comments: [], + commentsType: 'external', + preserveCommentsOnEmpty: false, + }); + + const exportedXml = editor.converter.convertedXml; + + // All comment files should be removed + expect(exportedXml['word/comments.xml']).toBeUndefined(); + expect(exportedXml['word/commentsExtended.xml']).toBeUndefined(); + expect(exportedXml['word/commentsExtensible.xml']).toBeUndefined(); + expect(exportedXml['word/commentsIds.xml']).toBeUndefined(); + } finally { + editor.destroy(); + } + }); + + it('replaces comments with provided array regardless of preserveCommentsOnEmpty flag', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const originalCommentCount = editor.converter.comments.length; + expect(originalCommentCount).toBeGreaterThan(0); + + // Create a single new comment for export + const singleComment = { + ...editor.converter.comments[0], + commentJSON: editor.converter.comments[0].textJson, + commentId: 'test-single-comment', + }; + + await editor.exportDocx({ + comments: [singleComment], + commentsType: 'external', + preserveCommentsOnEmpty: false, // flag shouldn't matter when array is non-empty + }); + + const exportedXml = editor.converter.convertedXml; + const commentsXml = exportedXml['word/comments.xml']; + const exportedComments = commentsXml?.elements?.[0]?.elements ?? []; + + // Should have exactly 1 comment (the one we passed) + expect(exportedComments).toHaveLength(1); + } finally { + editor.destroy(); + } + }); +}); + describe('Nested comments export', () => { const filename = 'nested-comments.docx'; let docx;