Skip to content

Commit 64d65a7

Browse files
fix: resync tracked-change bubbles on collaboration undo/redo (#2770)
* fix: resync tracked-change bubbles on collaboration undo/redo * fix(superdoc): sync tracked change comments on peer undo/redo replay --------- Co-authored-by: Nick Bernal <nick@superdoc.dev>
1 parent a7135ee commit 64d65a7

File tree

4 files changed

+325
-40
lines changed

4 files changed

+325
-40
lines changed

packages/superdoc/src/SuperDoc.test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { h, defineComponent, ref, shallowRef, reactive, nextTick } from 'vue';
44
import { DOCX } from '@superdoc/common';
55
import { Schema } from 'prosemirror-model';
66
import { EditorState, TextSelection } from 'prosemirror-state';
7+
import { ySyncPluginKey } from 'y-prosemirror';
78
import { Extension } from '../../super-editor/src/editors/v1/core/Extension.js';
89
import {
910
CommentsPlugin,
@@ -744,6 +745,7 @@ describe('SuperDoc.vue', () => {
744745
expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({
745746
superdoc: superdocStub,
746747
editor: editorMock,
748+
broadcastChanges: true,
747749
});
748750

749751
commentsStoreStub.syncTrackedChangePositionsWithDocument.mockClear();
@@ -762,9 +764,112 @@ describe('SuperDoc.vue', () => {
762764
expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({
763765
superdoc: superdocStub,
764766
editor: editorMock,
767+
broadcastChanges: true,
765768
});
766769
});
767770

771+
it('resyncs tracked-change threads on collaboration undo/redo transactions', async () => {
772+
const superdocStub = createSuperdocStub();
773+
const wrapper = await mountComponent(superdocStub);
774+
await nextTick();
775+
776+
const options = wrapper.findComponent(SuperEditorStub).props('options');
777+
const editorMock = { options: { documentId: 'doc-1' } };
778+
779+
const makeTransaction = ({ inputType, ySyncMeta } = {}) => ({
780+
getMeta: vi.fn((key) => {
781+
if (key === 'inputType') return inputType;
782+
if (key === ySyncPluginKey) return ySyncMeta;
783+
return undefined;
784+
}),
785+
});
786+
787+
options.onTransaction({
788+
editor: editorMock,
789+
transaction: makeTransaction({
790+
ySyncMeta: { isChangeOrigin: true, isUndoRedoOperation: true },
791+
}),
792+
duration: 4,
793+
});
794+
795+
expect(commentsStoreStub.syncTrackedChangePositionsWithDocument).toHaveBeenCalledWith({
796+
documentId: 'doc-1',
797+
editor: editorMock,
798+
});
799+
expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({
800+
superdoc: superdocStub,
801+
editor: editorMock,
802+
broadcastChanges: true,
803+
});
804+
});
805+
806+
it('resyncs tracked-change threads on peer collaboration replays', async () => {
807+
const superdocStub = createSuperdocStub();
808+
const wrapper = await mountComponent(superdocStub);
809+
await nextTick();
810+
811+
const options = wrapper.findComponent(SuperEditorStub).props('options');
812+
const editorMock = { options: { documentId: 'doc-1' } };
813+
814+
const makeTransaction = ({ inputType, ySyncMeta, docChanged = false } = {}) => ({
815+
docChanged,
816+
getMeta: vi.fn((key) => {
817+
if (key === 'inputType') return inputType;
818+
if (key === ySyncPluginKey) return ySyncMeta;
819+
return undefined;
820+
}),
821+
});
822+
823+
options.onTransaction({
824+
editor: editorMock,
825+
transaction: makeTransaction({
826+
docChanged: true,
827+
ySyncMeta: { isChangeOrigin: true, isUndoRedoOperation: false },
828+
}),
829+
duration: 6,
830+
});
831+
832+
expect(commentsStoreStub.syncTrackedChangePositionsWithDocument).toHaveBeenCalledWith({
833+
documentId: 'doc-1',
834+
editor: editorMock,
835+
});
836+
expect(commentsStoreStub.syncTrackedChangeComments).toHaveBeenCalledWith({
837+
superdoc: superdocStub,
838+
editor: editorMock,
839+
broadcastChanges: false,
840+
});
841+
});
842+
843+
it('does not resync tracked-change threads on meta-only collaboration updates', async () => {
844+
const superdocStub = createSuperdocStub();
845+
const wrapper = await mountComponent(superdocStub);
846+
await nextTick();
847+
848+
const options = wrapper.findComponent(SuperEditorStub).props('options');
849+
const editorMock = { options: { documentId: 'doc-1' } };
850+
851+
const makeTransaction = ({ inputType, ySyncMeta, docChanged = false } = {}) => ({
852+
docChanged,
853+
getMeta: vi.fn((key) => {
854+
if (key === 'inputType') return inputType;
855+
if (key === ySyncPluginKey) return ySyncMeta;
856+
return undefined;
857+
}),
858+
});
859+
860+
options.onTransaction({
861+
editor: editorMock,
862+
transaction: makeTransaction({
863+
docChanged: false,
864+
ySyncMeta: { isChangeOrigin: true, isUndoRedoOperation: false },
865+
}),
866+
duration: 7,
867+
});
868+
869+
expect(commentsStoreStub.syncTrackedChangePositionsWithDocument).not.toHaveBeenCalled();
870+
expect(commentsStoreStub.syncTrackedChangeComments).not.toHaveBeenCalled();
871+
});
872+
768873
it('reconciles replay updates by importedId before commentId to avoid duplicate comments', async () => {
769874
const superdocStub = createSuperdocStub();
770875
const wrapper = await mountComponent(superdocStub);

packages/superdoc/src/SuperDoc.vue

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { useCommentsStore } from '@superdoc/stores/comments-store';
3131

3232
import { DOCX, PDF, HTML } from '@superdoc/common';
3333
import { SuperEditor, AIWriter, PresentationEditor } from '@superdoc/super-editor';
34+
import { ySyncPluginKey } from 'y-prosemirror';
3435
import HtmlViewer from './components/HtmlViewer/HtmlViewer.vue';
3536
import useComment from './components/CommentsLayer/use-comment';
3637
import AiLayer from './components/AiLayer/AiLayer.vue';
@@ -1083,16 +1084,48 @@ const onEditorCommentsUpdate = (params = {}) => {
10831084
}
10841085
};
10851086

1087+
const isHistoryUndoRedoInput = (inputType) => inputType === 'historyUndo' || inputType === 'historyRedo';
1088+
1089+
const isCollaborationReplayTransaction = (transaction, ySyncMeta) => {
1090+
return Boolean(transaction?.docChanged && ySyncMeta?.isChangeOrigin);
1091+
};
1092+
1093+
const isPeerCollaborationReplayTransaction = (transaction, ySyncMeta) => {
1094+
const inputType = transaction?.getMeta?.('inputType');
1095+
return (
1096+
isCollaborationReplayTransaction(transaction, ySyncMeta) &&
1097+
!isHistoryUndoRedoInput(inputType) &&
1098+
!Boolean(ySyncMeta?.isUndoRedoOperation)
1099+
);
1100+
};
1101+
1102+
const shouldResyncTrackedChangeThreads = (transaction, ySyncMeta = transaction?.getMeta?.(ySyncPluginKey)) => {
1103+
const inputType = transaction?.getMeta?.('inputType');
1104+
const isLocalHistoryUndoRedo = isHistoryUndoRedoInput(inputType);
1105+
const isLocalCollabUndoRedo = Boolean(ySyncMeta?.isUndoRedoOperation);
1106+
1107+
// Peer editors do not retain the local UndoManager flag. A collaborator's
1108+
// undo/redo arrives as a generic Yjs-origin document replay, so treat those
1109+
// replays as tracked-change resync points and keep the resync path idempotent.
1110+
return isLocalHistoryUndoRedo || isLocalCollabUndoRedo || isCollaborationReplayTransaction(transaction, ySyncMeta);
1111+
};
1112+
10861113
const onEditorTransaction = (payload = {}) => {
10871114
const { editor, transaction } = payload;
1088-
const inputType = transaction?.getMeta?.('inputType');
1115+
const ySyncMeta = transaction?.getMeta?.(ySyncPluginKey);
10891116

1090-
// Call sync on editor transaction but only if it's undo or redo
1091-
// This could be extended to other listeners in the future
1092-
if (inputType === 'historyUndo' || inputType === 'historyRedo') {
1117+
// Call sync on editor transaction for undo/redo in both local history
1118+
// and collaboration replay modes.
1119+
if (shouldResyncTrackedChangeThreads(transaction, ySyncMeta)) {
10931120
const documentId = editor?.options?.documentId;
10941121
syncTrackedChangePositionsWithDocument({ documentId, editor });
1095-
syncTrackedChangeComments({ superdoc: proxy.$superdoc, editor });
1122+
syncTrackedChangeComments({
1123+
superdoc: proxy.$superdoc,
1124+
editor,
1125+
// Remote replay should rebuild only local sidebar state. The authoritative
1126+
// collaboration comment update is already shared through the comments ydoc.
1127+
broadcastChanges: !isPeerCollaborationReplayTransaction(transaction, ySyncMeta),
1128+
});
10961129
}
10971130

10981131
emitEditorTransaction(buildEditorTransactionPayload(payload));

0 commit comments

Comments
 (0)