Skip to content

Commit ff7d68f

Browse files
committed
fix(comments-store): preserve tracked-change thread state during undo/redo operations
1 parent 89ebfb6 commit ff7d68f

3 files changed

Lines changed: 83 additions & 11 deletions

File tree

packages/superdoc/src/SuperDoc.vue

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -990,8 +990,6 @@ const onEditorTransaction = (payload = {}) => {
990990
// This could be extended to other listeners in the future
991991
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
992992
const documentId = editor?.options?.documentId;
993-
// separation is useful because functions have different inputs
994-
// different side effects, and different reuse points.
995993
syncTrackedChangePositionsWithDocument({ documentId, editor });
996994
syncTrackedChangeComments({ superdoc: proxy.$superdoc, editor });
997995
}

packages/superdoc/src/stores/comments-store.js

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const useCommentsStore = defineStore('comments', () => {
2929

3030
const isDebugging = false;
3131
const debounceTimers = {};
32+
const trackedChangeResolutionSnapshots = new WeakMap();
3233

3334
const COMMENT_EVENTS = comments_module_events;
3435
const hasInitializedComments = ref(false);
@@ -188,6 +189,17 @@ export const useCommentsStore = defineStore('comments', () => {
188189

189190
const clearResolvedMetadata = (comment) => {
190191
if (!comment) return;
192+
if (
193+
comment.resolvedTime !== undefined ||
194+
comment.resolvedByEmail !== undefined ||
195+
comment.resolvedByName !== undefined
196+
) {
197+
trackedChangeResolutionSnapshots.set(comment, {
198+
resolvedTime: comment.resolvedTime ?? null,
199+
resolvedByEmail: comment.resolvedByEmail ?? null,
200+
resolvedByName: comment.resolvedByName ?? null,
201+
});
202+
}
191203
// Sets the resolved state to null so it can be restored in the comments sidebar
192204
comment.resolvedTime = null;
193205
comment.resolvedByEmail = null;
@@ -981,18 +993,18 @@ export const useCommentsStore = defineStore('comments', () => {
981993
// History replay can opt in to excluding resolved tracked-change threads so
982994
// undo/redo reopens them when their marks reappear. Initial import rebuilds
983995
// keep resolved IDs in the set so resolved DOCX threads do not reopen on load.
984-
const existingUnresolvedIds = new Set();
996+
const skipIds = new Set();
985997
commentsList.value.forEach((comment) => {
986998
if (!comment?.trackedChange) return;
987999
if (!belongsToDocument(comment, activeDocumentId)) return;
9881000
if (comment.resolvedTime && !reopenResolved) {
989-
if (comment.commentId != null) existingUnresolvedIds.add(String(comment.commentId));
990-
if (comment.importedId != null) existingUnresolvedIds.add(String(comment.importedId));
1001+
if (comment.commentId != null) skipIds.add(String(comment.commentId));
1002+
if (comment.importedId != null) skipIds.add(String(comment.importedId));
9911003
return;
9921004
}
9931005
if (comment.resolvedTime) return;
994-
if (comment.commentId != null) existingUnresolvedIds.add(String(comment.commentId));
995-
if (comment.importedId != null) existingUnresolvedIds.add(String(comment.importedId));
1006+
if (comment.commentId != null) skipIds.add(String(comment.commentId));
1007+
if (comment.importedId != null) skipIds.add(String(comment.importedId));
9961008
});
9971009

9981010
// Build a Map of change ID → tracked change entries for O(1) lookup per group.
@@ -1009,7 +1021,7 @@ export const useCommentsStore = defineStore('comments', () => {
10091021
// Build comment params directly from grouped changes — no PM dispatch needed
10101022
groupedChanges.forEach(({ insertedMark, deletionMark, formatMark }) => {
10111023
const id = insertedMark?.mark.attrs.id || deletionMark?.mark.attrs.id || formatMark?.mark.attrs.id;
1012-
if (!id || existingUnresolvedIds.has(id)) return;
1024+
if (!id || skipIds.has(id)) return;
10131025

10141026
const marks = {
10151027
...(insertedMark && { insertedMark: insertedMark.mark }),
@@ -1030,9 +1042,9 @@ export const useCommentsStore = defineStore('comments', () => {
10301042

10311043
if (params) {
10321044
handleTrackedChangeUpdate({ superdoc, params });
1033-
existingUnresolvedIds.add(String(id));
1034-
if (params.changeId != null) existingUnresolvedIds.add(String(params.changeId));
1035-
if (params.importedId != null) existingUnresolvedIds.add(String(params.importedId));
1045+
skipIds.add(String(id));
1046+
if (params.changeId != null) skipIds.add(String(params.changeId));
1047+
if (params.importedId != null) skipIds.add(String(params.importedId));
10361048
}
10371049
});
10381050

@@ -1096,6 +1108,15 @@ export const useCommentsStore = defineStore('comments', () => {
10961108
const hasLiveImportedId = Boolean(importedId && liveTrackedChangeIds.has(importedId));
10971109

10981110
if ((!commentId && !importedId) || hasLiveCommentId || hasLiveImportedId) return true;
1111+
if (comment.resolvedTime) return true;
1112+
1113+
const resolutionSnapshot = trackedChangeResolutionSnapshots.get(comment);
1114+
if (resolutionSnapshot) {
1115+
comment.resolvedTime = resolutionSnapshot.resolvedTime ?? Date.now();
1116+
comment.resolvedByEmail = resolutionSnapshot.resolvedByEmail ?? null;
1117+
comment.resolvedByName = resolutionSnapshot.resolvedByName ?? null;
1118+
return true;
1119+
}
10991120

11001121
if (commentId) removedIds.add(commentId);
11011122
if (importedId) removedIds.add(importedId);

packages/superdoc/src/stores/comments-store.test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,59 @@ describe('comments-store', () => {
940940
expect(editorDispatch).toHaveBeenCalledWith(tr);
941941
});
942942

943+
it('preserves tracked-change thread across accept undo redo undo history replay', () => {
944+
const editorDispatch = vi.fn();
945+
const tr = { setMeta: vi.fn() };
946+
const superdoc = { emit: vi.fn() };
947+
const editor = {
948+
state: {},
949+
view: { state: { tr }, dispatch: editorDispatch },
950+
options: { documentId: 'doc-1' },
951+
};
952+
953+
const rootComment = {
954+
commentId: 'tc-history-replay',
955+
trackedChange: true,
956+
trackedChangeText: 'Existing',
957+
resolvedTime: 123,
958+
resolvedByEmail: 'reviewer@example.com',
959+
resolvedByName: 'Reviewer',
960+
fileId: 'doc-1',
961+
getValues: vi.fn(() => ({ commentId: 'tc-history-replay' })),
962+
};
963+
const replyComment = {
964+
commentId: 'tc-history-replay-reply',
965+
parentCommentId: 'tc-history-replay',
966+
fileId: 'doc-1',
967+
};
968+
store.commentsList = [rootComment, replyComment];
969+
970+
// undo: accepted mark returns, thread reopens
971+
trackChangesHelpersMock.getTrackChanges.mockReturnValueOnce([{ mark: { attrs: { id: 'tc-history-replay' } } }]);
972+
groupChangesMock.mockReturnValueOnce([{ insertedMark: { mark: { attrs: { id: 'tc-history-replay' } } } }]);
973+
store.syncTrackedChangeComments({ superdoc, editor });
974+
975+
expect(rootComment.resolvedTime).toBeNull();
976+
expect(store.commentsList).toHaveLength(2);
977+
978+
// redo: accepted mark removed again, thread should not be deleted
979+
trackChangesHelpersMock.getTrackChanges.mockReturnValueOnce([]);
980+
groupChangesMock.mockReturnValueOnce([]);
981+
store.syncTrackedChangeComments({ superdoc, editor });
982+
983+
expect(store.commentsList).toHaveLength(2);
984+
expect(store.commentsList.find((comment) => comment.commentId === 'tc-history-replay')).toBeTruthy();
985+
expect(store.commentsList.find((comment) => comment.commentId === 'tc-history-replay-reply')).toBeTruthy();
986+
987+
// next undo: same original thread reopens, no rematerialized replacement thread
988+
trackChangesHelpersMock.getTrackChanges.mockReturnValueOnce([{ mark: { attrs: { id: 'tc-history-replay' } } }]);
989+
groupChangesMock.mockReturnValueOnce([{ insertedMark: { mark: { attrs: { id: 'tc-history-replay' } } } }]);
990+
store.syncTrackedChangeComments({ superdoc, editor });
991+
992+
expect(store.commentsList.filter((comment) => comment.commentId === 'tc-history-replay')).toHaveLength(1);
993+
expect(store.commentsList.filter((comment) => comment.commentId === 'tc-history-replay-reply')).toHaveLength(1);
994+
});
995+
943996
it('keeps tracked-change comments when importedId is live even if commentId differs', () => {
944997
const editorDispatch = vi.fn();
945998
const tr = { setMeta: vi.fn() };

0 commit comments

Comments
 (0)