Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 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,8 +2543,10 @@ 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]);
// 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'];
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
36 changes: 26 additions & 10 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 @@ -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
Expand Down Expand Up @@ -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 };
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
114 changes: 114 additions & 0 deletions packages/super-editor/src/tests/export/commentsRoundTrip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading