diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index 5e2ac3e5f0..38a39ef6df 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -721,17 +721,73 @@ const findTrackedMark = ({ }; const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEditorState, editor) => { - const { insertedMark, deletionMark, formatMark, deletionNodes } = trackedChangeMeta; + // Handle batched track changes from DOCX import + if (trackedChangeMeta.batchedTrackChanges) { + const newTrackedChanges = { ...trackedChanges }; + const emitParamsList = []; + + trackedChangeMeta.batchedTrackChanges.forEach(({ insertedMark, deletionMark, formatMark }) => { + const result = processSingleTrackChange( + { insertedMark, deletionMark, formatMark }, + newTrackedChanges, + newEditorState, + editor, + ); + if (result.emitParams) { + emitParamsList.push(result.emitParams); + } + }); + + // Emit all comments in a single batch event + if (emitParamsList.length > 0) { + editor.emit('commentsUpdate', { + type: comments_module_events.BATCH_ADD, + comments: emitParamsList, + }); + } + + return newTrackedChanges; + } + + // Handle single track change (original behavior for real-time changes) + const { insertedMark, deletionMark, formatMark } = trackedChangeMeta; if (!insertedMark && !deletionMark && !formatMark) { - return; + return trackedChanges; } const newTrackedChanges = { ...trackedChanges }; + const result = processSingleTrackChange( + { + insertedMark, + deletionMark, + formatMark, + step: trackedChangeMeta.step, + deletionNodes: trackedChangeMeta.deletionNodes, + }, + newTrackedChanges, + newEditorState, + editor, + ); + + if (result.emitParams) { + editor.emit('commentsUpdate', result.emitParams); + } + + return newTrackedChanges; +}; + +const processSingleTrackChange = (trackChangeData, newTrackedChanges, newEditorState, editor) => { + const { insertedMark, deletionMark, formatMark, step, deletionNodes } = trackChangeData; + + if (!insertedMark && !deletionMark && !formatMark) { + return { emitParams: null }; + } + let id = insertedMark?.attrs?.id || deletionMark?.attrs?.id || formatMark?.attrs?.id; if (!id) { - return trackedChanges; + return { emitParams: null }; } // Maintain a map of tracked changes with their inserted/deleted ids @@ -745,7 +801,6 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd if (deletionMark) newTrackedChanges[id].deletion = deletionMark.attrs?.id; if (formatMark) newTrackedChanges[id].format = formatMark.attrs?.id; - const { step } = trackedChangeMeta; let nodes = step?.slice?.content?.content || []; // Track format has no nodes, we need to find the node @@ -772,9 +827,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd newEditorState, }); - if (emitParams) editor.emit('commentsUpdate', emitParams); - - return newTrackedChanges; + return { emitParams }; }; const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isDeletionInsertion, marks }) => { diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.test.js b/packages/super-editor/src/extensions/comment/comments-plugin.test.js index f22b5e6861..08b244aabe 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.test.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.test.js @@ -865,10 +865,109 @@ describe('internal helper functions', () => { const editor = { options: { documentId: 'doc-1' }, emit: vi.fn() }; const result = handleTrackedChangeTransaction({ deletionNodes: [] }, { existing: 'value' }, state, editor); - expect(result).toBeUndefined(); + expect(result).toEqual({ existing: 'value' }); + expect(editor.emit).not.toHaveBeenCalled(); + }); + + it('handleTrackedChangeTransaction processes batched track changes and emits single BATCH_ADD event', () => { + const schema = createCommentSchema(); + const insertMark1 = schema.marks[TrackInsertMarkName].create({ + id: 'batch-change-1', + author: 'Alice', + authorEmail: 'alice@example.com', + date: 'today', + }); + const insertMark2 = schema.marks[TrackInsertMarkName].create({ + id: 'batch-change-2', + author: 'Bob', + authorEmail: 'bob@example.com', + date: 'today', + }); + const textNode1 = schema.text('First', [insertMark1]); + const textNode2 = schema.text('Second', [insertMark2]); + const paragraph = schema.node('paragraph', null, [textNode1, textNode2]); + const doc = schema.node('doc', null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const editor = { options: { documentId: 'doc-1' }, emit: vi.fn() }; + + const meta = { + batchedTrackChanges: [ + { insertedMark: insertMark1, deletionMark: null, formatMark: null }, + { insertedMark: insertMark2, deletionMark: null, formatMark: null }, + ], + }; + + const result = handleTrackedChangeTransaction(meta, {}, state, editor); + + // Should track both changes + expect(result['batch-change-1']).toMatchObject({ insertion: 'batch-change-1' }); + expect(result['batch-change-2']).toMatchObject({ insertion: 'batch-change-2' }); + + // Should emit a single BATCH_ADD event, not individual ADD events + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + type: comments_module_events.BATCH_ADD, + comments: expect.arrayContaining([ + expect.objectContaining({ changeId: 'batch-change-1' }), + expect.objectContaining({ changeId: 'batch-change-2' }), + ]), + }), + ); + }); + + it('handleTrackedChangeTransaction handles empty batchedTrackChanges array', () => { + const schema = createCommentSchema(); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('Text')])]); + const state = EditorState.create({ schema, doc }); + const editor = { options: { documentId: 'doc-1' }, emit: vi.fn() }; + + const meta = { + batchedTrackChanges: [], + }; + + const result = handleTrackedChangeTransaction(meta, { existing: 'value' }, state, editor); + + // Should return original state + expect(result).toEqual({ existing: 'value' }); + // Should not emit any event when batch is empty expect(editor.emit).not.toHaveBeenCalled(); }); + it('handleTrackedChangeTransaction filters out invalid track changes in batch', () => { + const schema = createCommentSchema(); + const insertMark = schema.marks[TrackInsertMarkName].create({ + id: 'valid-change', + author: 'Alice', + authorEmail: 'alice@example.com', + date: 'today', + }); + const textNode = schema.text('Valid', [insertMark]); + const paragraph = schema.node('paragraph', null, [textNode]); + const doc = schema.node('doc', null, [paragraph]); + const state = EditorState.create({ schema, doc }); + const editor = { options: { documentId: 'doc-1' }, emit: vi.fn() }; + + const meta = { + batchedTrackChanges: [ + { insertedMark: insertMark, deletionMark: null, formatMark: null }, + { insertedMark: null, deletionMark: null, formatMark: null }, // Invalid - no marks + ], + }; + + const result = handleTrackedChangeTransaction(meta, {}, state, editor); + + // Should only track the valid change + expect(result['valid-change']).toBeDefined(); + + // Should still emit with only valid comments + expect(editor.emit).toHaveBeenCalledTimes(1); + const emitCall = editor.emit.mock.calls[0][1]; + expect(emitCall.comments).toHaveLength(1); + expect(emitCall.comments[0].changeId).toBe('valid-change'); + }); + it('getTrackedChangeText extracts insertion, deletion, and format strings', () => { const schema = createCommentSchema(); const insertMark = schema.marks[TrackInsertMarkName].create({ id: 'insert-1' }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 3c377a045a..4fe6b3c6f4 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -554,7 +554,15 @@ const onEditorCommentLocationsUpdate = (doc, { allCommentIds: activeThreadId, al const onEditorCommentsUpdate = (params = {}) => { // Set the active comment in the store - let { activeCommentId, type, comment: commentPayload } = params; + let { activeCommentId, type, comment: commentPayload, comments: batchComments } = params; + + if (COMMENT_EVENTS?.BATCH_ADD && type === COMMENT_EVENTS.BATCH_ADD && batchComments?.length) { + batchComments.forEach((trackedChangeParams) => { + handleTrackedChangeUpdate({ superdoc: proxy.$superdoc, params: trackedChangeParams }); + }); + + return; + } if (COMMENT_EVENTS?.ADD && type === COMMENT_EVENTS.ADD && commentPayload) { if (!commentPayload.commentText && commentPayload.text) { diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 934e2e62ce..fc48aad75c 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -674,41 +674,45 @@ export const useCommentsStore = defineStore('comments', () => { const groupedChanges = groupChanges(trackedChanges); - // Create comments for tracked changes - // that do not have a corresponding comment (created in Word). - const { tr } = editor.view.state; - const { dispatch } = editor.view; + // Collect all track changes that need comments + const trackChangesToProcess = []; - groupedChanges.forEach(({ insertedMark, deletionMark, formatMark }, index) => { - console.debug(`Create comment for track change: ${index}`); + groupedChanges.forEach(({ insertedMark, deletionMark, formatMark }) => { const foundComment = commentsList.value.find( (i) => i.commentId === insertedMark?.mark.attrs.id || i.commentId === deletionMark?.mark.attrs.id || i.commentId === formatMark?.mark.attrs.id, ); - const isLastIteration = trackedChanges.length === index + 1; if (foundComment) { - if (isLastIteration) { - tr.setMeta(CommentsPluginKey, { type: 'force' }); - } return; } if (insertedMark || deletionMark || formatMark) { - const trackChangesPayload = { - ...(insertedMark && { insertedMark: insertedMark.mark }), - ...(deletionMark && { deletionMark: deletionMark.mark }), - ...(formatMark && { formatMark: formatMark.mark }), - }; - - if (isLastIteration) tr.setMeta(CommentsPluginKey, { type: 'force' }); - tr.setMeta(CommentsPluginKey, { type: 'forceTrackChanges' }); - tr.setMeta(TrackChangesBasePluginKey, trackChangesPayload); + trackChangesToProcess.push({ + insertedMark: insertedMark?.mark, + deletionMark: deletionMark?.mark, + formatMark: formatMark?.mark, + }); } - dispatch(tr); }); + + // If no track changes need processing, just force a UI refresh + if (trackChangesToProcess.length === 0) { + const { tr } = editor.view.state; + tr.setMeta(CommentsPluginKey, { type: 'force' }); + editor.view.dispatch(tr); + return; + } + + // Batch process all track changes with a single dispatch + const { tr } = editor.view.state; + tr.setMeta(CommentsPluginKey, { type: 'force' }); + tr.setMeta(TrackChangesBasePluginKey, { + batchedTrackChanges: trackChangesToProcess, + }); + editor.view.dispatch(tr); }; const translateCommentsForExport = () => { diff --git a/shared/common/event-types.ts b/shared/common/event-types.ts index 0d0e2115a9..86436688fe 100644 --- a/shared/common/event-types.ts +++ b/shared/common/event-types.ts @@ -2,6 +2,7 @@ export const comments_module_events = { RESOLVED: 'resolved', NEW: 'new', ADD: 'add', + BATCH_ADD: 'batch-add', UPDATE: 'update', DELETED: 'deleted', PENDING: 'pending',