Skip to content

Commit 595bdc6

Browse files
feat: comment anchor helpers (#1796)
* feat(comments): add anchor helpers * feat(comments): add JSDoc and tests for anchor helpers --------- Co-authored-by: cam <cam@camglynn.com>
1 parent aa7e957 commit 595bdc6

2 files changed

Lines changed: 197 additions & 9 deletions

File tree

packages/superdoc/src/stores/comments-store.js

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,96 @@ export const useCommentsStore = defineStore('comments', () => {
9393
return getComment(comment.parentCommentId);
9494
};
9595

96+
/**
97+
* Extract the position lookup key from a comment or comment ID.
98+
* Prefers importedId for imported comments since editor marks retain the original ID.
99+
*
100+
* @param {Object | string | null | undefined} commentOrId The comment object or comment ID
101+
* @returns {string | null} The position key (importedId or commentId)
102+
*/
103+
const getCommentPositionKey = (commentOrId) => {
104+
if (!commentOrId) return null;
105+
if (typeof commentOrId === 'object') {
106+
return commentOrId.importedId ?? commentOrId.commentId ?? null;
107+
}
108+
return commentOrId;
109+
};
110+
111+
/**
112+
* Normalize a position object to a consistent { start, end } format.
113+
* Handles different editor position schemas (start/end, pos/to, from/to).
114+
*
115+
* @param {Object | null | undefined} position The position object
116+
* @returns {{ start: number, end: number } | null} The normalized range or null
117+
*/
118+
const getCommentPositionRange = (position) => {
119+
if (!position) return null;
120+
const start = position.start ?? position.pos ?? position.from;
121+
const end = position.end ?? position.to ?? start;
122+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
123+
return { start, end };
124+
};
125+
126+
/**
127+
* Get the editor position data for a comment.
128+
*
129+
* @param {Object | string} commentOrId The comment object or comment ID
130+
* @returns {Object | null} The position data from editorCommentPositions
131+
*/
132+
const getCommentPosition = (commentOrId) => {
133+
const key = getCommentPositionKey(commentOrId);
134+
if (!key) return null;
135+
return editorCommentPositions.value?.[key] ?? null;
136+
};
137+
138+
/**
139+
* Get the text that a comment is anchored to in the document.
140+
*
141+
* @param {Object | string} commentOrId The comment object or comment ID
142+
* @param {Object} [options] Options for text extraction
143+
* @param {string} [options.separator=' '] Separator for textBetween when crossing nodes
144+
* @param {boolean} [options.trim=true] Whether to trim whitespace from the result
145+
* @returns {string | null} The anchored text or null if unavailable
146+
*/
147+
const getCommentAnchoredText = (commentOrId, options = {}) => {
148+
const key = getCommentPositionKey(commentOrId);
149+
if (!key) return null;
150+
151+
const comment = typeof commentOrId === 'object' ? commentOrId : getComment(commentOrId);
152+
if (!comment) return null;
153+
154+
const position = editorCommentPositions.value?.[key] ?? null;
155+
const range = getCommentPositionRange(position);
156+
if (!range) return null;
157+
158+
const doc = superdocStore.getDocument(comment.fileId);
159+
const editor = doc?.getEditor?.();
160+
const docNode = editor?.state?.doc;
161+
if (!docNode?.textBetween) return null;
162+
163+
const separator = options.separator ?? ' ';
164+
const text = docNode.textBetween(range.start, range.end, separator, separator);
165+
return options.trim === false ? text : text?.trim();
166+
};
167+
168+
/**
169+
* Get both position and anchored text data for a comment.
170+
*
171+
* @param {Object | string} commentOrId The comment object or comment ID
172+
* @param {Object} [options] Options passed to getCommentAnchoredText
173+
* @param {string} [options.separator=' '] Separator for textBetween when crossing nodes
174+
* @param {boolean} [options.trim=true] Whether to trim whitespace from the result
175+
* @returns {{ position: Object, anchoredText: string | null } | null} The anchor data or null
176+
*/
177+
const getCommentAnchorData = (commentOrId, options = {}) => {
178+
const position = getCommentPosition(commentOrId);
179+
if (!position) return null;
180+
return {
181+
position,
182+
anchoredText: getCommentAnchoredText(commentOrId, options),
183+
};
184+
};
185+
96186
const isThreadVisible = (comment) => {
97187
if (!isViewingMode.value) return true;
98188
const parent = getThreadParent(comment);
@@ -233,15 +323,6 @@ export const useCommentsStore = defineStore('comments', () => {
233323
activeComment.value = pendingComment.value.commentID;
234324
};
235325

236-
/**
237-
* Get the key used to look up a comment's position in editorCommentPositions.
238-
* Prefers importedId for imported comments since editor marks retain the original ID.
239-
*
240-
* @param {Object} comment - The comment object
241-
* @returns {string|undefined} The position lookup key
242-
*/
243-
const getCommentPositionKey = (comment) => comment?.importedId ?? comment?.commentId;
244-
245326
/**
246327
* Get the numeric position value for sorting a comment by document order.
247328
* Checks multiple position properties to handle different editor position schemas
@@ -782,6 +863,9 @@ export const useCommentsStore = defineStore('comments', () => {
782863
getGroupedComments,
783864
getCommentsByPosition,
784865
getFloatingComments,
866+
getCommentPosition,
867+
getCommentAnchoredText,
868+
getCommentAnchorData,
785869

786870
// Actions
787871
init,

packages/superdoc/src/stores/comments-store.test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,4 +536,108 @@ describe('comments-store', () => {
536536
expect(ordered).toEqual(['c-1', null, undefined]);
537537
});
538538
});
539+
540+
describe('comment anchor helpers', () => {
541+
it('returns comment position by id or comment object', () => {
542+
const comment = { commentId: 'c-1', fileId: 'doc-1' };
543+
store.commentsList = [comment];
544+
store.editorCommentPositions = {
545+
'c-1': { start: 12, end: 18 },
546+
};
547+
548+
expect(store.getCommentPosition('c-1')).toEqual({ start: 12, end: 18 });
549+
expect(store.getCommentPosition(comment)).toEqual({ start: 12, end: 18 });
550+
});
551+
552+
it('returns comment position using importedId fallback', () => {
553+
const comment = { importedId: 'imported-1', fileId: 'doc-1' };
554+
store.commentsList = [comment];
555+
store.editorCommentPositions = {
556+
'imported-1': { start: 20, end: 30 },
557+
};
558+
559+
expect(store.getCommentPosition('imported-1')).toEqual({ start: 20, end: 30 });
560+
expect(store.getCommentPosition(comment)).toEqual({ start: 20, end: 30 });
561+
});
562+
563+
it('returns anchored text when editor and positions are available', () => {
564+
const textBetween = vi.fn(() => 'Anchored text');
565+
const editorStub = { state: { doc: { textBetween } } };
566+
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];
567+
568+
store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
569+
store.editorCommentPositions = {
570+
'c-1': { start: 5, end: 12 },
571+
};
572+
573+
expect(store.getCommentAnchoredText('c-1')).toBe('Anchored text');
574+
expect(textBetween).toHaveBeenCalledWith(5, 12, ' ', ' ');
575+
});
576+
577+
it('returns anchored text with custom separator option', () => {
578+
const textBetween = vi.fn(() => 'Line1\nLine2');
579+
const editorStub = { state: { doc: { textBetween } } };
580+
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];
581+
582+
store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
583+
store.editorCommentPositions = {
584+
'c-1': { start: 0, end: 20 },
585+
};
586+
587+
expect(store.getCommentAnchoredText('c-1', { separator: '\n' })).toBe('Line1\nLine2');
588+
expect(textBetween).toHaveBeenCalledWith(0, 20, '\n', '\n');
589+
});
590+
591+
it('returns anchored text without trimming when trim is false', () => {
592+
const textBetween = vi.fn(() => ' spaced text ');
593+
const editorStub = { state: { doc: { textBetween } } };
594+
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];
595+
596+
store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
597+
store.editorCommentPositions = {
598+
'c-1': { start: 0, end: 15 },
599+
};
600+
601+
expect(store.getCommentAnchoredText('c-1', { trim: false })).toBe(' spaced text ');
602+
expect(store.getCommentAnchoredText('c-1')).toBe('spaced text');
603+
});
604+
605+
it('returns null when position or editor is missing', () => {
606+
store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
607+
store.editorCommentPositions = {};
608+
609+
expect(store.getCommentAnchoredText('c-1')).toBeNull();
610+
expect(store.getCommentAnchorData('c-1')).toBeNull();
611+
});
612+
613+
it('returns anchor data with position and text when available', () => {
614+
const textBetween = vi.fn(() => 'Selected text');
615+
const editorStub = { state: { doc: { textBetween } } };
616+
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];
617+
618+
store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
619+
store.editorCommentPositions = {
620+
'c-1': { start: 10, end: 25 },
621+
};
622+
623+
const result = store.getCommentAnchorData('c-1');
624+
expect(result).toEqual({
625+
position: { start: 10, end: 25 },
626+
anchoredText: 'Selected text',
627+
});
628+
});
629+
630+
it('handles empty anchored text', () => {
631+
const textBetween = vi.fn(() => '');
632+
const editorStub = { state: { doc: { textBetween } } };
633+
__mockSuperdoc.documents.value = [{ id: 'doc-1', type: 'docx', getEditor: () => editorStub }];
634+
635+
store.commentsList = [{ commentId: 'c-1', fileId: 'doc-1' }];
636+
store.editorCommentPositions = {
637+
'c-1': { start: 5, end: 5 },
638+
};
639+
640+
expect(store.getCommentAnchoredText('c-1')).toBe('');
641+
});
642+
});
539643
});

0 commit comments

Comments
 (0)