Skip to content
18 changes: 13 additions & 5 deletions packages/super-editor/src/core/DocxZipper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
47 changes: 28 additions & 19 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -2445,6 +2448,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
comments,
getUpdatedDocs = false,
fieldsHighlightColor = null,
preserveCommentsOnEmpty = true,
}: {
isFinalDoc?: boolean;
commentsType?: string;
Expand All @@ -2453,6 +2457,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
comments?: Comment[];
getUpdatedDocs?: boolean;
fieldsHighlightColor?: string | null;
preserveCommentsOnEmpty?: boolean;
} = {}): Promise<Blob | ArrayBuffer | Buffer | Record<string, string> | ProseMirrorJSON | string | undefined> {
try {
// Use provided comments, or fall back to imported comments from converter
Expand All @@ -2479,6 +2484,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
this,
exportJsonOnly,
fieldsHighlightColor,
preserveCommentsOnEmpty,
);

this.#validateDocumentExport();
Expand Down Expand Up @@ -2537,26 +2543,28 @@ export class Editor extends EventEmitter<EditorEventMap> {
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;
}
}

Expand Down Expand Up @@ -2851,6 +2859,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
commentsType: options?.commentsType,
comments: options?.comments,
fieldsHighlightColor: options?.fieldsHighlightColor,
preserveCommentsOnEmpty: options?.preserveCommentsOnEmpty,
});

return result as Blob | Buffer;
Expand Down
26 changes: 11 additions & 15 deletions packages/super-editor/src/core/super-converter/SuperConverter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
140 changes: 140 additions & 0 deletions packages/super-editor/src/tests/export/commentsRoundTrip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Expand Down Expand Up @@ -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;
Expand Down