Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -3351,13 +3351,12 @@ export class PresentationEditor extends EventEmitter {

// Emit fresh comment positions after layout completes.
// This ensures positions are always in sync with the current document and layout.
// Always emit — even when empty — so the store can clear stale positions
// (e.g. when undo removes the last tracked-change mark).
const allowViewingCommentPositions = this.#layoutOptions.emitCommentPositionsInViewing === true;
if (this.#documentMode !== 'viewing' || allowViewingCommentPositions) {
const commentPositions = this.#collectCommentPositions();
const positionKeys = Object.keys(commentPositions);
if (positionKeys.length > 0) {
this.emit('commentPositions', { positions: commentPositions });
}
this.emit('commentPositions', { positions: commentPositions });
}

this.#selectionSync.requestRender({ immediate: true });
Expand Down
17 changes: 12 additions & 5 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 = {};
let hasReceivedPositions = false;

const COMMENT_EVENTS = comments_module_events;
const hasInitializedComments = ref(false);
Expand Down Expand Up @@ -847,9 +848,14 @@ export const useCommentsStore = defineStore('comments', () => {
* @returns {void}
*/
const handleEditorLocationsUpdate = (allCommentPositions) => {
if ((!allCommentPositions || Object.keys(allCommentPositions).length === 0) && commentsList.value.length > 0) {
const isEmpty = !allCommentPositions || Object.keys(allCommentPositions).length === 0;
// Guard only during initial load (before any real positions arrive).
// Once positions have been received, allow empty updates so that
// undo of the last comment correctly clears positions.
if (isEmpty && commentsList.value.length > 0 && !hasReceivedPositions) {
return;
}
if (!isEmpty) hasReceivedPositions = true;
editorCommentPositions.value = allCommentPositions || {};
};

Expand All @@ -864,11 +870,12 @@ export const useCommentsStore = defineStore('comments', () => {
const comments = getGroupedComments.value?.parentComments
.filter((c) => !c.resolvedTime)
.filter((c) => {
const keys = Object.keys(editorCommentPositions.value);
const isPdfComment = c.selection?.source !== 'super-editor';
if (isPdfComment) return true;
// Non-editor comments (e.g. PDF) are always shown.
// Editor-backed comments (including tracked changes, which have no
// selection.source) must have a live position in the document.
if (!isEditorBackedComment(c)) return true;
const commentKey = c.commentId || c.importedId;
return keys.includes(commentKey);
return Object.keys(editorCommentPositions.value).includes(commentKey);
});
return comments;
});
Expand Down
94 changes: 94 additions & 0 deletions packages/superdoc/src/stores/comments-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,100 @@ describe('comments-store', () => {
});
});

describe('handleEditorLocationsUpdate', () => {
it('updates positions to empty after positions were established', () => {
store.commentsList = [{ commentId: 'c-1', createdTime: 1 }];

// First call with real positions — sets hasReceivedPositions
store.handleEditorLocationsUpdate({ 'c-1': { start: 1, end: 5 } });
expect(store.editorCommentPositions).toEqual({ 'c-1': { start: 1, end: 5 } });

// Second call with empty — should clear positions (undo scenario)
store.handleEditorLocationsUpdate({});
expect(store.editorCommentPositions).toEqual({});
});

it('guards against empty positions before any positions received', () => {
store.commentsList = [{ commentId: 'c-1', createdTime: 1 }];

// Call with empty before any real positions — guard should fire
store.handleEditorLocationsUpdate({});
expect(store.editorCommentPositions).toEqual({});

// Verify the guard kept the previous value (initial default is {})
store.editorCommentPositions = { stale: { start: 0, end: 0 } };
store.handleEditorLocationsUpdate({});
expect(store.editorCommentPositions).toEqual({ stale: { start: 0, end: 0 } });
});

it('redo restores comment after undo clears positions', () => {
store.commentsList = [{ commentId: 'c-1', createdTime: 1 }];

// Establish positions
store.handleEditorLocationsUpdate({ 'c-1': { start: 1, end: 5 } });
expect(store.editorCommentPositions).toEqual({ 'c-1': { start: 1, end: 5 } });

// Undo — clear positions
store.handleEditorLocationsUpdate({});
expect(store.editorCommentPositions).toEqual({});

// Redo — restore positions
store.handleEditorLocationsUpdate({ 'c-1': { start: 1, end: 5 } });
expect(store.editorCommentPositions).toEqual({ 'c-1': { start: 1, end: 5 } });
});

it('handles null input during initial load guard', () => {
store.commentsList = [{ commentId: 'c-1', createdTime: 1 }];

// null before any positions received — guard fires, no crash
expect(() => store.handleEditorLocationsUpdate(null)).not.toThrow();
});

it('allows empty update when commentsList is empty', () => {
store.commentsList = [];

// No comments in the store — guard condition is false, update proceeds
store.handleEditorLocationsUpdate({});
expect(store.editorCommentPositions).toEqual({});
});
});

describe('getFloatingComments — tracked-change undo', () => {
it('removes tracked-change comment from floating list when position is cleared', () => {
// Tracked-change comments have no selection.source
store.commentsList = [
{ commentId: 'tc-1', trackedChange: true, createdTime: 1, selection: { selectionBounds: {} } },
];

// Position established
store.handleEditorLocationsUpdate({ 'tc-1': { start: 10, end: 20 } });
expect(store.getFloatingComments).toHaveLength(1);
expect(store.getFloatingComments[0].commentId).toBe('tc-1');

// Undo removes all marks — positions cleared
store.handleEditorLocationsUpdate({});
expect(store.getFloatingComments).toHaveLength(0);
});

it('keeps non-editor comments visible regardless of positions', () => {
store.commentsList = [{ commentId: 'pdf-1', createdTime: 1, selection: { source: 'pdf', selectionBounds: {} } }];

store.editorCommentPositions = {};
expect(store.getFloatingComments).toHaveLength(1);
});

it('removes editor-backed comment without source when position is cleared', () => {
// Comments with no selection.source are editor-backed
store.commentsList = [{ commentId: 'c-1', createdTime: 1, selection: { selectionBounds: {} } }];

store.handleEditorLocationsUpdate({ 'c-1': { start: 5, end: 15 } });
expect(store.getFloatingComments).toHaveLength(1);

store.handleEditorLocationsUpdate({});
expect(store.getFloatingComments).toHaveLength(0);
});
});

describe('document-driven resolution state', () => {
it('clears resolved metadata when document anchors reappear', async () => {
const comment = {
Expand Down