diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 018ceb96c7..a664b96fb1 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -144,7 +144,7 @@ class DocxZipper { const hasFile = (filename) => { if (updatedDocs && Object.prototype.hasOwnProperty.call(updatedDocs, filename)) { - return true; + return updatedDocs[filename] !== null; } if (!docx?.files) return false; if (!fromJson) return Boolean(docx.files[filename]); @@ -292,10 +292,14 @@ class DocxZipper { zip.file(file.name, content); } - // Replace updated docs + // Replace updated docs (null values remove the file from the zip) Object.keys(updatedDocs).forEach((key) => { const content = updatedDocs[key]; - zip.file(key, content); + if (content === null) { + zip.remove(key); + } else { + zip.file(key, content); + } }); Object.keys(media).forEach((path) => { @@ -330,9 +334,13 @@ class DocxZipper { }); await Promise.all(filePromises); - // Make replacements of updated docs + // Make replacements of updated docs (null values remove the file from the zip) Object.keys(updatedDocs).forEach((key) => { - unzippedOriginalDocx.file(key, updatedDocs[key]); + if (updatedDocs[key] === null) { + unzippedOriginalDocx.remove(key); + } else { + unzippedOriginalDocx.file(key, updatedDocs[key]); + } }); Object.keys(media).forEach((path) => { diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 4652c1570e..e01658377d 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,26 +2543,28 @@ 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]); - updatedDocs['word/comments.xml'] = String(commentsXml); - - const commentsExtended = this.converter.convertedXml['word/commentsExtended.xml']; - if (commentsExtended?.elements?.[0]) { - const commentsExtendedXml = this.converter.schemaToXml(commentsExtended.elements[0]); - updatedDocs['word/commentsExtended.xml'] = String(commentsExtendedXml); - } - - const commentsExtensible = this.converter.convertedXml['word/commentsExtensible.xml']; - if (commentsExtensible?.elements?.[0]) { - const commentsExtensibleXml = this.converter.schemaToXml(commentsExtensible.elements[0]); - updatedDocs['word/commentsExtensible.xml'] = String(commentsExtensibleXml); + // Serialize comment files if they exist, or mark them for removal from the zip + const commentFileKeys = [ + 'word/comments.xml', + 'word/commentsExtended.xml', + 'word/commentsExtensible.xml', + 'word/commentsIds.xml', + ] as const; + + const commentsFile = this.converter.convertedXml['word/comments.xml']; + if (commentsFile?.elements?.[0]) { + updatedDocs['word/comments.xml'] = String(this.converter.schemaToXml(commentsFile.elements[0])); + + for (const key of commentFileKeys.slice(1)) { + const file = this.converter.convertedXml[key]; + if (file?.elements?.[0]) { + updatedDocs[key] = String(this.converter.schemaToXml(file.elements[0])); + } } - - const commentsIds = this.converter.convertedXml['word/commentsIds.xml']; - if (commentsIds?.elements?.[0]) { - const commentsIdsXml = this.converter.schemaToXml(commentsIds.elements[0]); - updatedDocs['word/commentsIds.xml'] = String(commentsIdsXml); + } else { + // Comments were removed — tell DocxZipper to strip these files from the zip + for (const key of commentFileKeys) { + updatedDocs[key] = null as unknown as string; } } @@ -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 b8f1b74912..4d954fdb9e 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); @@ -1018,20 +1019,14 @@ class SuperConverter { editor, ); - // Update content types and comments files as needed - let updatedXml = { ...this.convertedXml }; - let commentsRels = []; - if (comments.length) { - const { documentXml, relationships } = this.#prepareCommentsXmlFilesForExport({ - defs: params.exportedCommentDefs, - exportType: commentsExportType, - commentsWithParaIds, - }); - updatedXml = { ...documentXml }; - commentsRels = relationships; - } - - this.convertedXml = { ...this.convertedXml, ...updatedXml }; + // Update comments files based on export type and preserveCommentsOnEmpty flag + const { documentXml, relationships: commentsRels } = this.#prepareCommentsXmlFilesForExport({ + defs: params.exportedCommentDefs, + exportType: commentsExportType, + commentsWithParaIds, + preserveCommentsOnEmpty, + }); + this.convertedXml = documentXml; const headFootRels = this.#exportProcessHeadersFooters({ isFinalDoc }); @@ -1111,13 +1106,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 8758b491a4..90db332dd7 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 32c3f4432b..d1dc57c710 100644 --- a/packages/super-editor/src/tests/export/commentsRoundTrip.test.js +++ b/packages/super-editor/src/tests/export/commentsRoundTrip.test.js @@ -2,6 +2,7 @@ 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'; +import DocxZipper from '@core/DocxZipper.js'; const extractNodeText = (node) => { if (!node) return ''; @@ -367,6 +368,145 @@ 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('removes comment files from the exported zip when preserveCommentsOnEmpty is false', async () => { + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + const originalCommentCount = editor.converter.comments.length; + expect(originalCommentCount).toBeGreaterThan(0); + + const zipped = await editor.exportDocx({ + comments: [], + commentsType: 'external', + preserveCommentsOnEmpty: false, + }); + + const zipper = new DocxZipper(); + const zip = await zipper.unzip(zipped); + + expect(zip.file('word/comments.xml')).toBeNull(); + expect(zip.file('word/commentsExtended.xml')).toBeNull(); + expect(zip.file('word/commentsExtensible.xml')).toBeNull(); + expect(zip.file('word/commentsIds.xml')).toBeNull(); + } 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;