Skip to content
Merged
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
102 changes: 93 additions & 9 deletions packages/superdoc/src/stores/comments-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,96 @@ export const useCommentsStore = defineStore('comments', () => {
return getComment(comment.parentCommentId);
};

/**
* Extract the position lookup key from a comment or comment ID.
* Prefers importedId for imported comments since editor marks retain the original ID.
*
* @param {Object | string | null | undefined} commentOrId The comment object or comment ID
* @returns {string | null} The position key (importedId or commentId)
*/
const getCommentPositionKey = (commentOrId) => {
if (!commentOrId) return null;
if (typeof commentOrId === 'object') {
return commentOrId.importedId ?? commentOrId.commentId ?? null;
}
return commentOrId;
};

/**
* Normalize a position object to a consistent { start, end } format.
* Handles different editor position schemas (start/end, pos/to, from/to).
*
* @param {Object | null | undefined} position The position object
* @returns {{ start: number, end: number } | null} The normalized range or null
*/
const getCommentPositionRange = (position) => {
if (!position) return null;
const start = position.start ?? position.pos ?? position.from;
const end = position.end ?? position.to ?? start;
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
return { start, end };
};

/**
* Get the editor position data for a comment.
*
* @param {Object | string} commentOrId The comment object or comment ID
* @returns {Object | null} The position data from editorCommentPositions
*/
const getCommentPosition = (commentOrId) => {
const key = getCommentPositionKey(commentOrId);
if (!key) return null;
return editorCommentPositions.value?.[key] ?? null;
};

/**
* Get the text that a comment is anchored to in the document.
*
* @param {Object | string} commentOrId The comment object or comment ID
* @param {Object} [options] Options for text extraction
* @param {string} [options.separator=' '] Separator for textBetween when crossing nodes
* @param {boolean} [options.trim=true] Whether to trim whitespace from the result
* @returns {string | null} The anchored text or null if unavailable
*/
const getCommentAnchoredText = (commentOrId, options = {}) => {
const key = getCommentPositionKey(commentOrId);
if (!key) return null;

const comment = typeof commentOrId === 'object' ? commentOrId : getComment(commentOrId);
if (!comment) return null;

const position = editorCommentPositions.value?.[key] ?? null;
const range = getCommentPositionRange(position);
if (!range) return null;

const doc = superdocStore.getDocument(comment.fileId);
const editor = doc?.getEditor?.();
const docNode = editor?.state?.doc;
if (!docNode?.textBetween) return null;

const separator = options.separator ?? ' ';
const text = docNode.textBetween(range.start, range.end, separator, separator);
return options.trim === false ? text : text?.trim();
};

/**
* Get both position and anchored text data for a comment.
*
* @param {Object | string} commentOrId The comment object or comment ID
* @param {Object} [options] Options passed to getCommentAnchoredText
* @param {string} [options.separator=' '] Separator for textBetween when crossing nodes
* @param {boolean} [options.trim=true] Whether to trim whitespace from the result
* @returns {{ position: Object, anchoredText: string | null } | null} The anchor data or null
*/
const getCommentAnchorData = (commentOrId, options = {}) => {
const position = getCommentPosition(commentOrId);
if (!position) return null;
return {
position,
anchoredText: getCommentAnchoredText(commentOrId, options),
};
};

const isThreadVisible = (comment) => {
if (!isViewingMode.value) return true;
const parent = getThreadParent(comment);
Expand Down Expand Up @@ -233,15 +323,6 @@ export const useCommentsStore = defineStore('comments', () => {
activeComment.value = pendingComment.value.commentID;
};

/**
* Get the key used to look up a comment's position in editorCommentPositions.
* Prefers importedId for imported comments since editor marks retain the original ID.
*
* @param {Object} comment - The comment object
* @returns {string|undefined} The position lookup key
*/
const getCommentPositionKey = (comment) => comment?.importedId ?? comment?.commentId;

/**
* Get the numeric position value for sorting a comment by document order.
* Checks multiple position properties to handle different editor position schemas
Expand Down Expand Up @@ -782,6 +863,9 @@ export const useCommentsStore = defineStore('comments', () => {
getGroupedComments,
getCommentsByPosition,
getFloatingComments,
getCommentPosition,
getCommentAnchoredText,
getCommentAnchorData,

// Actions
init,
Expand Down
104 changes: 104 additions & 0 deletions packages/superdoc/src/stores/comments-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,4 +536,108 @@ describe('comments-store', () => {
expect(ordered).toEqual(['c-1', null, undefined]);
});
});

describe('comment anchor helpers', () => {
it('returns comment position by id or comment object', () => {
const comment = { commentId: 'c-1', fileId: 'doc-1' };
store.commentsList = [comment];
store.editorCommentPositions = {
'c-1': { start: 12, end: 18 },
};

expect(store.getCommentPosition('c-1')).toEqual({ start: 12, end: 18 });
expect(store.getCommentPosition(comment)).toEqual({ start: 12, end: 18 });
});

it('returns comment position using importedId fallback', () => {
const comment = { importedId: 'imported-1', fileId: 'doc-1' };
store.commentsList = [comment];
store.editorCommentPositions = {
'imported-1': { start: 20, end: 30 },
};

expect(store.getCommentPosition('imported-1')).toEqual({ start: 20, end: 30 });
expect(store.getCommentPosition(comment)).toEqual({ start: 20, end: 30 });
});

it('returns anchored text when editor and positions are available', () => {
const textBetween = vi.fn(() => 'Anchored text');
const editorStub = { state: { doc: { textBetween } } };
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];

store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
store.editorCommentPositions = {
'c-1': { start: 5, end: 12 },
};

expect(store.getCommentAnchoredText('c-1')).toBe('Anchored text');
expect(textBetween).toHaveBeenCalledWith(5, 12, ' ', ' ');
});

it('returns anchored text with custom separator option', () => {
const textBetween = vi.fn(() => 'Line1\nLine2');
const editorStub = { state: { doc: { textBetween } } };
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];

store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
store.editorCommentPositions = {
'c-1': { start: 0, end: 20 },
};

expect(store.getCommentAnchoredText('c-1', { separator: '\n' })).toBe('Line1\nLine2');
expect(textBetween).toHaveBeenCalledWith(0, 20, '\n', '\n');
});

it('returns anchored text without trimming when trim is false', () => {
const textBetween = vi.fn(() => ' spaced text ');
const editorStub = { state: { doc: { textBetween } } };
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];

store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
store.editorCommentPositions = {
'c-1': { start: 0, end: 15 },
};

expect(store.getCommentAnchoredText('c-1', { trim: false })).toBe(' spaced text ');
expect(store.getCommentAnchoredText('c-1')).toBe('spaced text');
});

it('returns null when position or editor is missing', () => {
store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
store.editorCommentPositions = {};

expect(store.getCommentAnchoredText('c-1')).toBeNull();
expect(store.getCommentAnchorData('c-1')).toBeNull();
});

it('returns anchor data with position and text when available', () => {
const textBetween = vi.fn(() => 'Selected text');
const editorStub = { state: { doc: { textBetween } } };
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];

store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
store.editorCommentPositions = {
'c-1': { start: 10, end: 25 },
};

const result = store.getCommentAnchorData('c-1');
expect(result).toEqual({
position: { start: 10, end: 25 },
anchoredText: 'Selected text',
});
});

it('handles empty anchored text', () => {
const textBetween = vi.fn(() => '');
const editorStub = { state: { doc: { textBetween } } };
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];

store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
store.editorCommentPositions = {
'c-1': { start: 5, end: 5 },
};

expect(store.getCommentAnchoredText('c-1')).toBe('');
});
});
});