Skip to content
Merged
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
17 changes: 13 additions & 4 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2373,7 +2373,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
commentsType = 'external',
exportJsonOnly = false,
exportXmlOnly = false,
comments = [],
comments,
getUpdatedDocs = false,
fieldsHighlightColor = null,
}: {
Expand All @@ -2386,8 +2386,17 @@ export class Editor extends EventEmitter<EditorEventMap> {
fieldsHighlightColor?: string | null;
} = {}): Promise<Blob | ArrayBuffer | Buffer | Record<string, string> | ProseMirrorJSON | string | undefined> {
try {
// Use provided comments, or fall back to imported comments from converter
const effectiveComments = comments ?? this.converter.comments ?? [];

// Normalize commentJSON property (imported comments use textJson)
const preparedComments = effectiveComments.map((comment: Comment) => ({
...comment,
commentJSON: comment.commentJSON ?? (comment as Record<string, unknown>).textJson,
}));

// Pre-process the document state to prepare for export
const json = this.#prepareDocumentForExport(comments);
const json = this.#prepareDocumentForExport(preparedComments);

// Export the document to DOCX
// GUID will be handled automatically in converter.exportToDocx if document was modified
Expand All @@ -2397,7 +2406,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
(this.storage.image as ImageStorage).media,
isFinalDoc,
commentsType,
comments,
preparedComments,
this,
exportJsonOnly,
fieldsHighlightColor,
Expand Down Expand Up @@ -2459,7 +2468,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
updatedDocs['word/_rels/footnotes.xml.rels'] = String(footnotesRelsXml);
}

if (comments.length) {
if (preparedComments.length) {
const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml'].elements[0]);
updatedDocs['word/comments.xml'] = String(commentsXml);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ const decode = (params) => {

const commentNodeId = node.attrs['w:id'];

// Use String() for consistent comparison since commentNodeId comes from XML (string)
// while commentId/importedId may be UUID strings or numeric IDs
const nodeIdStr = String(commentNodeId);
const originalComment = comments.find((comment) => {
return comment.commentId == commentNodeId;
return String(comment.commentId) === nodeIdStr || String(comment.importedId) === nodeIdStr;
});
if (!originalComment) return;

Expand Down
187 changes: 129 additions & 58 deletions packages/super-editor/src/extensions/comment/comments-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,83 +219,118 @@ export const prepareCommentsForExport = (doc, tr, schema, comments = []) => {
return comment.parentCommentId;
};

// Note: Parent/child relationships are tracked via comment.parentCommentId property
const startNodes = [];
const endNodes = [];
const seen = new Set();
const trackedChangeCommentMeta = new Map();
// First pass: collect full ranges for each comment mark
// Map of commentId -> { start: number, end: number, attrs: object }
const commentRanges = new Map();
const commentTrackedChangeId = new Map();

doc.descendants((node, pos) => {
const commentMarks = node.marks?.filter((mark) => mark.type.name === CommentMarkName) || [];
if (!commentMarks.length) return;

const nodeEnd = pos + node.nodeSize;
const trackedChangeMark = node.marks?.find((mark) => TRACK_CHANGE_MARKS.includes(mark.type.name));
const trackedChangeId = trackedChangeMark?.attrs?.id;

commentMarks.forEach((commentMark) => {
const { attrs = {} } = commentMark;
const { commentId } = attrs;

if (commentId === 'pending') return;
if (seen.has(commentId)) return;

const comment = commentMap.get(commentId);
const parentCommentId = getThreadingParentId(comment);
const trackedChangeMark = node.marks?.find((mark) => TRACK_CHANGE_MARKS.includes(mark.type.name));
const trackedChangeId = trackedChangeMark?.attrs?.id;
const trackedSpan = trackedChangeId ? trackedChangeSpanById.get(trackedChangeId) : null;
if (trackedSpan) {
trackedChangeCommentMeta.set(commentId, {
comment,
parentCommentId,
trackedChangeId,

if (!commentRanges.has(commentId)) {
// First occurrence - record start and end
commentRanges.set(commentId, {
start: pos,
end: nodeEnd,
attrs,
});
seen.add(commentId);
return;
} else {
// Extend the range to include this node
const existing = commentRanges.get(commentId);
existing.start = Math.min(existing.start, pos);
existing.end = Math.max(existing.end, nodeEnd);
}

seen.add(commentId);
if (trackedChangeId && !commentTrackedChangeId.has(commentId)) {
commentTrackedChangeId.set(commentId, trackedChangeId);
}
});
});

const commentStartNodeAttrs = getPreparedComment(commentMark.attrs);
const startNode = schema.nodes.commentRangeStart.create(commentStartNodeAttrs);
startNodes.push({
pos,
node: startNode,
commentId,
parentCommentId,
});
// Note: Parent/child relationships are tracked via comment.parentCommentId property
const startNodes = [];
const endNodes = [];
const seen = new Set();
const trackedChangeCommentMeta = new Map();

const endNode = schema.nodes.commentRangeEnd.create(commentStartNodeAttrs);
endNodes.push({
pos: pos + node.nodeSize,
node: endNode,
commentId,
// Second pass: create start/end nodes using the full ranges
commentRanges.forEach(({ start, end, attrs }, commentId) => {
if (seen.has(commentId)) return;
seen.add(commentId);

const comment = commentMap.get(commentId);
const parentCommentId = getThreadingParentId(comment);
const trackedChangeId = commentTrackedChangeId.get(commentId);
const trackedSpan = trackedChangeId ? trackedChangeSpanById.get(trackedChangeId) : null;
if (trackedSpan) {
trackedChangeCommentMeta.set(commentId, {
comment,
parentCommentId,
trackedChangeId,
});
return;
}

// Find child comments that should be nested inside this comment
const childComments = comments
.filter((c) => getThreadingParentId(c) === commentId)
.sort((a, b) => a.createdTime - b.createdTime);
const commentStartNodeAttrs = getPreparedComment(attrs);
const startNode = schema.nodes.commentRangeStart.create(commentStartNodeAttrs);
startNodes.push({
pos: start,
node: startNode,
commentId,
parentCommentId,
});

childComments.forEach((c) => {
if (seen.has(c.commentId)) return;
seen.add(c.commentId);
const endNode = schema.nodes.commentRangeEnd.create(commentStartNodeAttrs);
endNodes.push({
pos: end,
node: endNode,
commentId,
parentCommentId,
});

const childMark = getPreparedComment({
commentId: c.commentId,
internal: c.isInternal,
});
const childStartNode = schema.nodes.commentRangeStart.create(childMark);
startNodes.push({
pos,
node: childStartNode,
commentId: c.commentId,
parentCommentId: getThreadingParentId(c),
});
// Find child comments that should be nested inside this comment
const childComments = comments
.filter((c) => getThreadingParentId(c) === commentId)
.sort((a, b) => a.createdTime - b.createdTime);

const childEndNode = schema.nodes.commentRangeEnd.create(childMark);
endNodes.push({
pos: pos + node.nodeSize,
node: childEndNode,
commentId: c.commentId,
parentCommentId: getThreadingParentId(c),
});
childComments.forEach((c) => {
if (seen.has(c.commentId)) return;
seen.add(c.commentId);

// Check if child has its own range in the document
const childRange = commentRanges.get(c.commentId);
const childStart = childRange?.start ?? start;
const childEnd = childRange?.end ?? end;

const childMark = getPreparedComment({
commentId: c.commentId,
internal: c.isInternal,
});
const childStartNode = schema.nodes.commentRangeStart.create(childMark);
startNodes.push({
pos: childStart,
node: childStartNode,
commentId: c.commentId,
parentCommentId: getThreadingParentId(c),
});

const childEndNode = schema.nodes.commentRangeEnd.create(childMark);
endNodes.push({
pos: childEnd,
node: childEndNode,
commentId: c.commentId,
parentCommentId: getThreadingParentId(c),
});
});
});
Expand Down Expand Up @@ -339,6 +374,42 @@ export const prepareCommentsForExport = (doc, tr, schema, comments = []) => {
commentId: comment.commentId,
parentCommentId,
});

const childComments = comments
.filter((c) => getThreadingParentId(c) === comment.commentId)
.sort((a, b) => a.createdTime - b.createdTime);

childComments.forEach((c) => {
if (seen.has(c.commentId)) return;
seen.add(c.commentId);

const childRange = commentRanges.get(c.commentId);
const childStart = childRange?.start ?? span.startPos;
const childEnd = childRange?.end ?? span.endPos;
const childStartMarks = childRange ? undefined : startMarks;
const childEndMarks = childRange ? undefined : endMarks;

const childMarkAttrs = getPreparedComment({
commentId: c.commentId,
internal: c.isInternal,
});

const childStartNode = schema.nodes.commentRangeStart.create(childMarkAttrs, null, childStartMarks);
startNodes.push({
pos: childStart,
node: childStartNode,
commentId: c.commentId,
parentCommentId: getThreadingParentId(c),
});

const childEndNode = schema.nodes.commentRangeEnd.create(childMarkAttrs, null, childEndMarks);
endNodes.push({
pos: childEnd,
node: childEndNode,
commentId: c.commentId,
parentCommentId: getThreadingParentId(c),
});
});
});

comments
Expand Down
1 change: 0 additions & 1 deletion packages/super-editor/src/extensions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ import { PermissionRanges } from './permission-ranges/index.js';
import { PermStart } from './perm-start/index.js';
import { PermEnd } from './perm-end/index.js';


// Helpers
import { trackChangesHelpers } from './track-changes/index.js';

Expand Down
Binary file not shown.
103 changes: 103 additions & 0 deletions packages/super-editor/src/tests/export/commentsRoundTrip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,106 @@ describe('Resolved comments round-trip', () => {
}
});
});

describe('Nested comments export', () => {
const filename = 'nested-comments.docx';
let docx;
let media;
let mediaFiles;
let fonts;

beforeAll(async () => {
({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(filename));
});

/**
* Find all commentRangeStart and commentRangeEnd elements in order of appearance.
* Returns array of { type: 'start'|'end', id: string } in document order.
*/
const findCommentRangeEventsInOrder = (documentXml) => {
const events = [];

const traverse = (elements) => {
if (!Array.isArray(elements)) return;
for (const el of elements) {
if (el.name === 'w:commentRangeStart') {
events.push({ type: 'start', id: el.attributes?.['w:id'] });
}
if (el.name === 'w:commentRangeEnd') {
events.push({ type: 'end', id: el.attributes?.['w:id'] });
}
if (el.elements) traverse(el.elements);
}
};

traverse(documentXml?.elements || []);
return events;
};

it('exports nested comment ranges with correct structure (outer wraps inner)', async () => {
const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts });

try {
// Verify we have multiple comments (nested structure)
expect(editor.converter.comments.length).toBeGreaterThanOrEqual(2);

// Export without explicitly passing comments - should use converter.comments
await editor.exportDocx({
commentsType: 'external',
});

const exportedXml = editor.converter.convertedXml;
const documentXml = exportedXml['word/document.xml'];

// Get comment range events in document order
const events = findCommentRangeEventsInOrder(documentXml);

// Verify we have start and end events for all comments
const starts = events.filter((e) => e.type === 'start');
const ends = events.filter((e) => e.type === 'end');
expect(starts.length).toBeGreaterThanOrEqual(2);
expect(ends.length).toBe(starts.length);

// For proper nesting, if comment A starts before comment B,
// then comment B must end before comment A (stack-like behavior).
// This means: starts[0].id should equal ends[ends.length - 1].id
// (the first to start should be the last to end)
const firstStartId = starts[0].id;
const lastEndId = ends[ends.length - 1].id;
expect(firstStartId).toBe(lastEndId);

// Verify all IDs have both start and end
const startIds = new Set(starts.map((e) => e.id));
const endIds = new Set(ends.map((e) => e.id));
expect(startIds).toEqual(endIds);
} finally {
editor.destroy();
}
});

it('preserves nested comments through round-trip without explicit comments array', async () => {
const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts });

try {
const originalCommentCount = editor.converter.comments.length;

// Export without passing comments - relies on fallback to converter.comments
await editor.exportDocx({
commentsType: 'external',
});

const exportedXml = carbonCopy(editor.converter.convertedXml);

// Verify comments.xml has all comments
const commentsXml = exportedXml['word/comments.xml'];
const exportedComments = commentsXml?.elements?.[0]?.elements ?? [];
expect(exportedComments.length).toBe(originalCommentCount);

// Re-import and verify comments are preserved
const reimportedComments = importCommentData({ docx: exportedXml }) ?? [];
expect(reimportedComments.length).toBe(originalCommentCount);
} finally {
editor.destroy();
}
});
});
Loading