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
5 changes: 1 addition & 4 deletions packages/layout-engine/pm-adapter/src/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ describe('shiftBlockPositions', () => {
const block: ParagraphBlock = {
kind: 'paragraph',
id: 'p1',
runs: [
{ text: 'hello', pmStart: 10, pmEnd: 15 } as Run,
{ text: 'world', pmStart: 15, pmEnd: 20 } as Run,
],
runs: [{ text: 'hello', pmStart: 10, pmEnd: 15 } as Run, { text: 'world', pmStart: 15, pmEnd: 20 } as Run],
};

const shifted = shiftBlockPositions(block, 5) as ParagraphBlock;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2968,9 +2968,7 @@ export class PresentationEditor extends EventEmitter {
previousMeasures,
);
const incrementalLayoutEnd = perfNow();
perfLog(
`[Perf] incrementalLayout: ${(incrementalLayoutEnd - incrementalLayoutStart).toFixed(2)}ms`,
);
perfLog(`[Perf] incrementalLayout: ${(incrementalLayoutEnd - incrementalLayoutStart).toFixed(2)}ms`);

// Type guard: validate incrementalLayout return value
if (!result || typeof result !== 'object') {
Expand Down
9 changes: 8 additions & 1 deletion packages/superdoc/src/SuperDoc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,11 @@ const editorOptions = (doc) => {
proxy.$superdoc.listeners?.('fonts-resolved')?.length > 0 ? proxy.$superdoc.listeners('fonts-resolved')[0] : null;
const useLayoutEngine = proxy.$superdoc.config.useLayoutEngine !== false;

const ydocFragment = doc.ydoc?.getXmlFragment?.('supereditor');
const ydocMeta = doc.ydoc?.getMap?.('meta');
const ydocHasContent = (ydocFragment && ydocFragment.length > 0) || (ydocMeta && Boolean(ydocMeta.get('docx')));
const isNewFile = doc.isNewFile && !ydocHasContent;

const options = {
isDebug: proxy.$superdoc.config.isDebug || false,
documentId: doc.id,
Expand Down Expand Up @@ -493,7 +498,7 @@ const editorOptions = (doc) => {
onTransaction: onEditorTransaction,
ydoc: doc.ydoc,
collaborationProvider: doc.provider || null,
isNewFile: doc.isNewFile || false,
isNewFile,
handleImageUpload: proxy.$superdoc.config.handleImageUpload,
externalExtensions: proxy.$superdoc.config.editorExtensions || [],
suppressDefaultDocxStyles: proxy.$superdoc.config.suppressDefaultDocxStyles,
Expand Down Expand Up @@ -537,6 +542,7 @@ const onEditorCommentLocationsUpdate = (doc, { allCommentIds: activeThreadId, al
commentsStore.clearEditorCommentPositions?.();
return;
}
if (!allCommentPositions || Object.keys(allCommentPositions).length === 0) return;

const presentation = PresentationEditor.getInstance(doc.id);
if (!presentation) {
Expand All @@ -549,6 +555,7 @@ const onEditorCommentLocationsUpdate = (doc, { allCommentIds: activeThreadId, al
// Note: PresentationEditor's 'commentPositions' event provides fresh positions
// after every layout, so this is mainly for the initial load before layout completes.
const mappedPositions = presentation.getCommentBounds(allCommentPositions, layers.value);
if (!mappedPositions || Object.keys(mappedPositions).length === 0) return;
handleEditorLocationsUpdate(mappedPositions, activeThreadId);
};

Expand Down
3 changes: 3 additions & 0 deletions packages/superdoc/src/core/SuperDoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ export class SuperDoc extends EventEmitter {
this.superdocStore.init(this.config);
const commentsModuleConfig = this.config.modules.comments;
this.commentsStore.init(commentsModuleConfig && commentsModuleConfig !== false ? commentsModuleConfig : {});
if (this.isCollaborative) {
initCollaborationComments(this);
}
this.#syncViewingVisibility();
}

Expand Down
53 changes: 53 additions & 0 deletions packages/superdoc/src/core/collaboration/collaboration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vite
import * as collaborationModule from './collaboration.js';
import {
initCollaborationComments,
loadCommentsFromYdoc,
initSuperdocYdoc,
makeDocumentsCollaborative,
syncCommentsToClients,
Expand Down Expand Up @@ -292,6 +293,58 @@ describe('collaboration helpers', () => {
expect(useCommentMock).toHaveBeenCalledTimes(2);
});

it('initCollaborationComments loads existing comments from ydoc on init', () => {
commentsArray.items = [
new MockYMap(Object.entries({ commentId: 'c1', text: 'Hello' })),
new MockYMap(Object.entries({ commentId: 'c1', text: 'Duplicate' })),
new MockYMap(Object.entries({ commentId: 'c2', text: 'Another' })),
];

initCollaborationComments(superdoc);

expect(useCommentMock).toHaveBeenCalledTimes(2);
expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }, { normalized: 'c2' }]);
});

it('loadCommentsFromYdoc hydrates comments from importedId and deduplicates by stable key', () => {
commentsArray.items = [
new MockYMap(Object.entries({ importedId: 'legacy-1', text: 'legacy without commentId' })),
new MockYMap(Object.entries({ importedId: 'legacy-1', text: 'duplicate legacy' })),
new MockYMap(Object.entries({ commentId: 'c2', text: 'normal comment' })),
new MockYMap(Object.entries({ commentId: 'c2', text: 'duplicate normal comment' })),
];
superdoc.provider.synced = true;

const loaded = loadCommentsFromYdoc(superdoc);

expect(loaded).toBe(true);
expect(useCommentMock).toHaveBeenCalledTimes(2);
expect(useCommentMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ importedId: 'legacy-1', commentId: 'legacy-1' }),
);
expect(useCommentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ commentId: 'c2' }));
expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'legacy-1' }, { normalized: 'c2' }]);
expect(superdoc.commentsStore.hasSyncedCollaborationComments).toBe(true);
});

it('initCollaborationComments re-hydrates store on repeated init without duplicating listeners', () => {
commentsArray.items = [new MockYMap(Object.entries({ commentId: 'c1', text: 'first' }))];

initCollaborationComments(superdoc);
expect(commentsArray._observers.size).toBe(1);
expect(superdoc.provider.on).toHaveBeenCalledTimes(1);
expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }]);

// Simulate store reset after mount; second init should re-hydrate but not add listeners again.
superdoc.commentsStore.commentsList = [];
initCollaborationComments(superdoc);

expect(commentsArray._observers.size).toBe(1);
expect(superdoc.provider.on).toHaveBeenCalledTimes(1);
expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }]);
});

it('initCollaborationComments skips when module disabled', () => {
superdoc.config.modules.comments = false;
initCollaborationComments(superdoc);
Expand Down
72 changes: 54 additions & 18 deletions packages/superdoc/src/core/collaboration/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@ import useComment from '../../components/CommentsLayer/use-comment';

import { addYComment, updateYComment, deleteYComment } from './collaboration-comments';

/**
* Load comments from the ydoc into the comments store.
*
* @param {Object} superdoc The SuperDoc instance
* @returns {boolean} True if comments were loaded into the store
*/
export const loadCommentsFromYdoc = (superdoc) => {
if (!superdoc?.ydoc || !superdoc?.commentsStore) return false;
const commentsArray = superdoc.ydoc.getArray('comments');
const comments = commentsArray.toJSON();
const seen = new Set();
const filtered = [];
comments.forEach((c) => {
const key = c?.importedId ?? c?.commentId;
if (!key || seen.has(key)) return;
seen.add(key);
if (!c?.commentId) {
filtered.push({ ...c, commentId: key });
return;
}
filtered.push(c);
});
superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c));
if (superdoc.provider?.synced) {
superdoc.commentsStore.hasSyncedCollaborationComments = true;
}
return true;
};

/**
* Initialize sync for comments if the module is enabled
*
Expand All @@ -11,44 +40,51 @@ import { addYComment, updateYComment, deleteYComment } from './collaboration-com
*/
export const initCollaborationComments = (superdoc) => {
if (!superdoc.config.modules.comments || !superdoc.provider) return;
if (superdoc._commentsCollabInitialized) {
loadCommentsFromYdoc(superdoc);
return;
}
superdoc._commentsCollabInitialized = true;

// If we have comments and collaboration, wait for sync and then let the store know when its ready
const commentsArray = superdoc.ydoc.getArray('comments');
const updateCommentsStore = () => loadCommentsFromYdoc(superdoc);

const onSuperDocYdocSynced = () => {
if (!updateCommentsStore()) {
setTimeout(updateCommentsStore, 0);
}
// Update the editor comment locations
const parent = superdoc.commentsStore.commentsParentElement;
const ids = superdoc.commentsStore.editorCommentIds;
superdoc.commentsStore.handleEditorLocationsUpdate(parent, ids);
superdoc.commentsStore.hasSyncedCollaborationComments = true;
if (superdoc.commentsStore) {
const parent = superdoc.commentsStore.commentsParentElement;
const ids = superdoc.commentsStore.editorCommentIds;
superdoc.commentsStore.handleEditorLocationsUpdate(parent, ids);
superdoc.commentsStore.hasSyncedCollaborationComments = true;
}

superdoc.provider.off('synced', onSuperDocYdocSynced);
};

// Listen for the synced event
superdoc.provider.on('synced', onSuperDocYdocSynced);

// Get the comments map from the Y.Doc
const commentsArray = superdoc.ydoc.getArray('comments');
// Load any existing comments immediately (in case provider synced before we subscribed)
if (!updateCommentsStore()) {
setTimeout(updateCommentsStore, 0);
}

// Observe changes to the comments map
commentsArray.observe((event) => {
if (!superdoc.commentsStore) return;
// Ignore events if triggered by the current user
const currentUser = superdoc.config.user;
const { user = {} } = event.transaction.origin;
const origin = event?.transaction?.origin;
const { user = {} } = origin || {};

if (currentUser.name === user.name && currentUser.email === user.email) return;

// Update conversations
const comments = commentsArray.toJSON();

const seen = new Set();
const filtered = [];
comments.forEach((c) => {
if (!seen.has(c.commentId)) {
seen.add(c.commentId);
filtered.push(c);
}
});
superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c));
updateCommentsStore();
});
};

Expand Down
6 changes: 6 additions & 0 deletions packages/superdoc/src/stores/comments-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,9 @@ export const useCommentsStore = defineStore('comments', () => {
const deleteComment = ({ commentId: commentIdToDelete, superdoc }) => {
const commentIndex = commentsList.value.findIndex((c) => c.commentId === commentIdToDelete);
const comment = commentsList.value[commentIndex];
if (!comment) {
return;
}
const { commentId, importedId } = comment;
const { fileId } = comment;

Expand Down Expand Up @@ -821,6 +824,9 @@ export const useCommentsStore = defineStore('comments', () => {
* @returns {void}
*/
const handleEditorLocationsUpdate = (allCommentPositions) => {
if ((!allCommentPositions || Object.keys(allCommentPositions).length === 0) && commentsList.value.length > 0) {
return;
}
editorCommentPositions.value = allCommentPositions || {};
};

Expand Down
Loading