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
67 changes: 60 additions & 7 deletions packages/super-editor/src/extensions/comment/comments-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }) => {
Expand Down
101 changes: 100 additions & 1 deletion packages/super-editor/src/extensions/comment/comments-plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
10 changes: 9 additions & 1 deletion packages/superdoc/src/SuperDoc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
44 changes: 24 additions & 20 deletions packages/superdoc/src/stores/comments-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
1 change: 1 addition & 0 deletions shared/common/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading