diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 770faf505..64c1e176d 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -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); @@ -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 @@ -782,6 +863,9 @@ export const useCommentsStore = defineStore('comments', () => { getGroupedComments, getCommentsByPosition, getFloatingComments, + getCommentPosition, + getCommentAnchoredText, + getCommentAnchorData, // Actions init, diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 6d5a312b4..b8d8e3330 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -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(''); + }); + }); });