Skip to content
Merged
47 changes: 47 additions & 0 deletions packages/superdoc/src/SuperDoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ const buildCommentsStore = () => ({
handleEditorLocationsUpdate: vi.fn(),
clearEditorCommentPositions: vi.fn(),
handleTrackedChangeUpdate: vi.fn(),
syncTrackedChangePositionsWithDocument: vi.fn(),
syncTrackedChangeComments: vi.fn(),
removePendingComment: vi.fn(),
setActiveComment: vi.fn(),
Expand Down Expand Up @@ -519,6 +520,52 @@ describe('SuperDoc.vue', () => {
expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled();
});

it('resyncs tracked-change threads on undo/redo transactions', async () => {
const superdocStub = createSuperdocStub();
const wrapper = await mountComponent(superdocStub);
await nextTick();

const options = wrapper.findComponent(SuperEditorStub).props('options');
const editorMock = { options: { documentId: 'doc-1' } };

const makeTransaction = (inputType) => ({
getMeta: vi.fn((key) => (key === 'inputType' ? inputType : undefined)),
});

options.onTransaction({
editor: editorMock,
transaction: makeTransaction('historyUndo'),
duration: 4,
});

expect(commentsStoreStub.syncTrackedChangePositionsWithDocument).toHaveBeenCalledWith({
documentId: 'doc-1',
editor: editorMock,
});
expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({
superdoc: superdocStub,
editor: editorMock,
});

commentsStoreStub.syncTrackedChangePositionsWithDocument.mockClear();
commentsStoreStub.syncTrackedChangeComments.mockClear();

options.onTransaction({
editor: editorMock,
transaction: makeTransaction('historyRedo'),
duration: 5,
});

expect(commentsStoreStub.syncTrackedChangePositionsWithDocument).toHaveBeenCalledWith({
documentId: 'doc-1',
editor: editorMock,
});
expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({
superdoc: superdocStub,
editor: editorMock,
});
});

it('reconciles replay updates by importedId before commentId to avoid duplicate comments', async () => {
const superdocStub = createSuperdocStub();
const wrapper = await mountComponent(superdocStub);
Expand Down
1 change: 1 addition & 0 deletions packages/superdoc/src/SuperDoc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,7 @@ const onEditorTransaction = (payload = {}) => {
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
const documentId = editor?.options?.documentId;
syncTrackedChangePositionsWithDocument({ documentId, editor });
syncTrackedChangeComments({ superdoc: proxy.$superdoc, editor });
Comment thread
palmer-cl marked this conversation as resolved.
}

emitEditorTransaction(buildEditorTransactionPayload(payload));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ const mountDialog = async ({ baseCommentOverrides = {}, extraComments = [], prop
resolveComment: vi.fn(),
},
},
focus: vi.fn(),
emit: vi.fn(),
};

Expand Down Expand Up @@ -282,15 +283,19 @@ describe('CommentDialog.vue', () => {

const header = wrapper.findComponent(CommentHeaderStub);
header.vm.$emit('resolve');
await nextTick();
expect(superdocStub.activeEditor.commands.acceptTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId);
expect(baseComment.resolveComment).toHaveBeenCalledWith({
email: superdocStoreStub.user.email,
name: superdocStoreStub.user.name,
superdoc: expect.any(Object),
});
expect(superdocStub.focus).toHaveBeenCalledTimes(1);

header.vm.$emit('reject');
await nextTick();
expect(superdocStub.activeEditor.commands.rejectTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId);
expect(superdocStub.focus).toHaveBeenCalledTimes(2);
});

it('calls custom accept handler instead of default behavior when configured', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ const handleReject = () => {
commentsStore.lastUpdate = new Date();
activeComment.value = null;
commentsStore.setActiveComment(proxy.$superdoc, activeComment.value);
proxy.$superdoc.focus?.();
});
};

Expand Down Expand Up @@ -427,6 +428,7 @@ const handleResolve = () => {
commentsStore.lastUpdate = new Date();
activeComment.value = null;
commentsStore.setActiveComment(proxy.$superdoc, activeComment.value);
proxy.$superdoc.focus?.();
});
};

Expand Down
93 changes: 81 additions & 12 deletions packages/superdoc/src/stores/comments-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const useCommentsStore = defineStore('comments', () => {

const isDebugging = false;
const debounceTimers = {};
const trackedChangeResolutionSnapshots = new WeakMap();

const COMMENT_EVENTS = comments_module_events;
const hasInitializedComments = ref(false);
Expand Down Expand Up @@ -188,12 +189,26 @@ export const useCommentsStore = defineStore('comments', () => {

const clearResolvedMetadata = (comment) => {
if (!comment) return;
if (
comment.resolvedTime !== undefined ||
comment.resolvedByEmail !== undefined ||
comment.resolvedByName !== undefined
) {
trackedChangeResolutionSnapshots.set(comment, {
resolvedTime: comment.resolvedTime ?? null,
resolvedByEmail: comment.resolvedByEmail ?? null,
resolvedByName: comment.resolvedByName ?? null,
});
}
// Sets the resolved state to null so it can be restored in the comments sidebar
comment.resolvedTime = null;
comment.resolvedByEmail = null;
comment.resolvedByName = null;
};

const getCommentEventPayload = (comment) =>
typeof comment?.getValues === 'function' ? comment.getValues() : { ...comment };

/**
* Check if a comment originated from the super-editor (or has no explicit source).
* Comments without a source are assumed to be editor-backed for backward compatibility.
Expand Down Expand Up @@ -512,6 +527,10 @@ export const useCommentsStore = defineStore('comments', () => {
if (event === 'add') {
const existing = findTrackedChangeById();
if (existing) {
// Undo/redo after accept/reject can rematerialize a previously resolved
// tracked change. Reopen the thread so the bubble is actionable again.
if (existing.resolvedTime) clearResolvedMetadata(existing);

// Already exists (e.g. created during batch import) — update instead of duplicating
// Partial resolution can turn a replacement into insert-only/delete-only, so
// clear fields explicitly when the updated payload no longer includes them.
Expand All @@ -533,6 +552,7 @@ export const useCommentsStore = defineStore('comments', () => {
// If we have an update event, simply update the composable comment
const existingTrackedChange = findTrackedChangeById();
if (!existingTrackedChange) return;
if (existingTrackedChange.resolvedTime) clearResolvedMetadata(existingTrackedChange);

// Partial resolution can turn a replacement into insert-only/delete-only, so
// clear fields explicitly when the updated payload no longer includes them.
Expand Down Expand Up @@ -960,21 +980,31 @@ export const useCommentsStore = defineStore('comments', () => {
}, 0);
};

const createCommentForTrackChanges = (editor, superdoc, trackedChangesOverride = null) => {
const createCommentForTrackChanges = (editor, superdoc, trackedChangesOverride = null, options = {}) => {
const { reopenResolved = false } = options;
const trackedChanges = trackedChangesOverride ?? trackChangesHelpers.getTrackChanges(editor.state);
const groupedChanges = groupChanges(trackedChanges);
const activeDocumentId = editor?.options?.documentId != null ? String(editor.options.documentId) : null;
if (!activeDocumentId) return;

// Build a Set of existing tracked-change IDs for O(1) lookup.
// Build a Set of existing unresolved tracked-change IDs for O(1) lookup.
// Include both runtime and imported IDs to avoid duplicate threads when
// replay/import flows remap commentId but marks still reference importedId.
const existingIds = new Set();
// History replay can opt in to excluding resolved tracked-change threads so
// undo/redo reopens them when their marks reappear. Initial import rebuilds
// keep resolved IDs in the set so resolved DOCX threads do not reopen on load.
const skipIds = new Set();
commentsList.value.forEach((comment) => {
if (!comment?.trackedChange) return;
if (!belongsToDocument(comment, activeDocumentId)) return;
if (comment.commentId != null) existingIds.add(String(comment.commentId));
if (comment.importedId != null) existingIds.add(String(comment.importedId));
if (comment.resolvedTime && !reopenResolved) {
if (comment.commentId != null) skipIds.add(String(comment.commentId));
if (comment.importedId != null) skipIds.add(String(comment.importedId));
return;
}
if (comment.resolvedTime) return;
if (comment.commentId != null) skipIds.add(String(comment.commentId));
if (comment.importedId != null) skipIds.add(String(comment.importedId));
});

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

const marks = {
...(insertedMark && { insertedMark: insertedMark.mark }),
Expand All @@ -1012,9 +1042,9 @@ export const useCommentsStore = defineStore('comments', () => {

if (params) {
handleTrackedChangeUpdate({ superdoc, params });
existingIds.add(String(id));
if (params.changeId != null) existingIds.add(String(params.changeId));
if (params.importedId != null) existingIds.add(String(params.importedId));
skipIds.add(String(id));
if (params.changeId != null) skipIds.add(String(params.changeId));
if (params.importedId != null) skipIds.add(String(params.importedId));
}
});

Expand Down Expand Up @@ -1062,10 +1092,11 @@ export const useCommentsStore = defineStore('comments', () => {
* @param {string | null} activeDocumentId Document currently being synced.
* @returns {void}
*/
const pruneStaleTrackedChangeComments = (liveTrackedChangeIds, activeDocumentId) => {
const pruneStaleTrackedChangeComments = (liveTrackedChangeIds, activeDocumentId, superdoc = null) => {
if (!(liveTrackedChangeIds instanceof Set) || !activeDocumentId) return;

const removedIds = new Set();
const restoredComments = [];
const previousComments = [...commentsList.value];

commentsList.value = commentsList.value.filter((comment) => {
Expand All @@ -1078,12 +1109,32 @@ export const useCommentsStore = defineStore('comments', () => {
const hasLiveImportedId = Boolean(importedId && liveTrackedChangeIds.has(importedId));

if ((!commentId && !importedId) || hasLiveCommentId || hasLiveImportedId) return true;
if (comment.resolvedTime) return true;

const resolutionSnapshot = trackedChangeResolutionSnapshots.get(comment);
if (resolutionSnapshot) {
comment.resolvedTime = resolutionSnapshot.resolvedTime ?? Date.now();
comment.resolvedByEmail = resolutionSnapshot.resolvedByEmail ?? null;
comment.resolvedByName = resolutionSnapshot.resolvedByName ?? null;
restoredComments.push(comment);
return true;
}

if (commentId) removedIds.add(commentId);
if (importedId) removedIds.add(importedId);
return false;
});

restoredComments.forEach((comment) => {
const payload = getCommentEventPayload(comment);
const event = {
type: COMMENT_EVENTS.UPDATE,
comment: payload,
};
syncCommentsToClients(superdoc, event);
superdoc?.emit?.('comments-update', event);
});

if (!removedIds.size) return;

let didRemoveDescendants = true;
Expand All @@ -1110,6 +1161,24 @@ export const useCommentsStore = defineStore('comments', () => {
});
}

const removedComments = previousComments.filter((comment) => {
if (!belongsToDocument(comment, activeDocumentId)) return false;
const commentId = comment.commentId != null ? String(comment.commentId) : null;
const importedId = comment.importedId != null ? String(comment.importedId) : null;
return (commentId && removedIds.has(commentId)) || (importedId && removedIds.has(importedId));
});

removedComments.forEach((comment) => {
Comment thread
palmer-cl marked this conversation as resolved.
const payload = getCommentEventPayload(comment);
const event = {
type: COMMENT_EVENTS.DELETED,
comment: payload,
changes: [{ key: 'deleted', commentId: payload.commentId, fileId: payload.fileId }],
};
syncCommentsToClients(superdoc, event);
superdoc?.emit?.('comments-update', event);
});

const activeCommentId = activeComment.value != null ? String(activeComment.value) : null;
const activeCommentBelongsToActiveDocument = previousComments.some((comment) => {
const commentId = comment.commentId != null ? String(comment.commentId) : null;
Expand Down Expand Up @@ -1148,8 +1217,8 @@ export const useCommentsStore = defineStore('comments', () => {
liveTrackedChangeIds.add(String(id));
});

pruneStaleTrackedChangeComments(liveTrackedChangeIds, activeDocumentId);
createCommentForTrackChanges(editor, superdoc, trackedChanges);
pruneStaleTrackedChangeComments(liveTrackedChangeIds, activeDocumentId, superdoc);
createCommentForTrackChanges(editor, superdoc, trackedChanges, { reopenResolved: true });
};

const normalizeDocxSchemaForExport = (value) => {
Expand Down
Loading
Loading