Skip to content

Commit 0132d0e

Browse files
feat(comments): add scrollToComment API (#2440)
* feat(comments): add scrollToComment API * fix(comments): scope scrollToComment to container, escape selector - Scope querySelector to this.element to avoid cross-instance collisions - Escape commentId to prevent DOMException on special characters - Add test for missing comment element returning false * refactor(comments): delegate Vue scrollToComment to core method - Remove duplicated logic from SuperDoc.vue, delegate to SuperDoc.scrollToComment() so the Vue path gets CSS escaping and container scoping for free - Guard against null options to prevent TypeError * test(comments): add behavior test for scrollToComment API * fix(comments): use data-comment-ids attribute for scrollToComment The rendered comment highlights use data-comment-ids (set by DomPainter), not data-thread-id (which only exists on hidden ProseMirror decorations). * fix(test): poll scrollToComment in behavior test for WebKit timing * docs(comments): add scrollToComment API documentation --------- Co-authored-by: cam <cam@camglynn.com>
1 parent 129772f commit 0132d0e

6 files changed

Lines changed: 201 additions & 8 deletions

File tree

apps/docs/core/superdoc/methods.mdx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,61 @@ const superdoc = new SuperDoc({
572572

573573
</CodeGroup>
574574

575+
### `scrollToComment`
576+
577+
Scroll to a comment in the document and set it as active.
578+
579+
<ParamField path="commentId" type="string" required>
580+
The comment ID to scroll to
581+
</ParamField>
582+
583+
<ParamField path="options" type="object">
584+
Scroll behavior options
585+
<Expandable>
586+
<ParamField path="behavior" type="ScrollBehavior" default="smooth">
587+
Scroll behavior — `"smooth"`, `"instant"`, or `"auto"`
588+
</ParamField>
589+
<ParamField path="block" type="ScrollLogicalPosition" default="start">
590+
Vertical alignment — `"start"`, `"center"`, `"end"`, or `"nearest"`
591+
</ParamField>
592+
</Expandable>
593+
</ParamField>
594+
595+
**Returns:** `boolean``true` if the comment was found, `false` otherwise.
596+
597+
<CodeGroup>
598+
599+
```javascript Usage
600+
// Get a comment ID from the document API
601+
const { items } = superdoc.editor.doc.comments.list();
602+
const commentId = items[0].id;
603+
604+
// Scroll to it
605+
superdoc.scrollToComment(commentId);
606+
```
607+
608+
```javascript Full Example
609+
import { SuperDoc } from 'superdoc';
610+
import 'superdoc/style.css';
611+
612+
const superdoc = new SuperDoc({
613+
selector: '#editor',
614+
document: yourFile,
615+
modules: { comments: {} },
616+
onReady: (superdoc) => {
617+
const { items } = superdoc.editor.doc.comments.list();
618+
if (items.length > 0) {
619+
superdoc.scrollToComment(items[0].id, {
620+
behavior: 'smooth',
621+
block: 'center',
622+
});
623+
}
624+
},
625+
});
626+
```
627+
628+
</CodeGroup>
629+
575630
## User management
576631

577632
### `addSharedUser`

apps/docs/modules/comments.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,39 @@ const superdoc = new SuperDoc({
546546
547547
</CodeGroup>
548548
549+
### `scrollToComment`
550+
551+
Scroll the document to a comment and set it as active. Unlike `setCursorById`, this is a top-level SuperDoc method that works without accessing the editor directly.
552+
553+
<CodeGroup>
554+
555+
```javascript Usage
556+
superdoc.scrollToComment("comment-123");
557+
```
558+
559+
```javascript Full Example
560+
import { SuperDoc } from 'superdoc';
561+
import 'superdoc/style.css';
562+
563+
const superdoc = new SuperDoc({
564+
selector: '#editor',
565+
document: yourFile,
566+
modules: { comments: {} },
567+
onReady: (superdoc) => {
568+
const { items } = superdoc.editor.doc.comments.list();
569+
if (items.length > 0) {
570+
superdoc.scrollToComment(items[0].id);
571+
}
572+
},
573+
});
574+
```
575+
576+
</CodeGroup>
577+
578+
<Tip>
579+
See [SuperDoc Methods](/core/superdoc/methods#scrolltocomment) for full parameter documentation.
580+
</Tip>
581+
549582
## Events
550583
551584
### `onCommentsUpdate`

packages/superdoc/src/SuperDoc.vue

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,14 +1028,7 @@ watch(showCommentsSidebar, (value) => {
10281028
* @param {String} commentId The commentId to scroll to
10291029
*/
10301030
const scrollToComment = (commentId) => {
1031-
const commentsConfig = proxy.$superdoc.config?.modules?.comments;
1032-
if (!commentsConfig || commentsConfig === false) return;
1033-
1034-
const element = document.querySelector(`[data-thread-id=${commentId}]`);
1035-
if (element) {
1036-
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
1037-
commentsStore.setActiveComment(proxy.$superdoc, commentId);
1038-
}
1031+
proxy.$superdoc.scrollToComment(commentId);
10391032
};
10401033
10411034
onMounted(() => {

packages/superdoc/src/core/SuperDoc.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,29 @@ export class SuperDoc extends EventEmitter {
803803
}
804804
}
805805

806+
/**
807+
* Scroll the document to a given comment by id.
808+
*
809+
* @param {string} commentId The comment id
810+
* @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options]
811+
* @returns {boolean} Whether a matching element was found
812+
*/
813+
scrollToComment(commentId, options = {}) {
814+
const commentsConfig = this.config?.modules?.comments;
815+
if (!commentsConfig || commentsConfig === false) return false;
816+
if (!commentId || typeof commentId !== 'string') return false;
817+
818+
const root = this.element || document;
819+
const escaped = globalThis.CSS?.escape ? globalThis.CSS.escape(commentId) : commentId.replace(/"/g, '\\"');
820+
const element = root.querySelector(`[data-comment-ids*="${escaped}"]`);
821+
if (!element) return false;
822+
823+
const { behavior = 'smooth', block = 'start' } = options ?? {};
824+
element.scrollIntoView({ behavior, block });
825+
this.commentsStore?.setActiveComment?.(this, commentId);
826+
return true;
827+
}
828+
806829
/**
807830
* Toggle the custom context menu globally.
808831
* Updates both flow editors and PresentationEditor instances so downstream listeners can short-circuit early.

packages/superdoc/src/core/SuperDoc.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,44 @@ describe('SuperDoc core', () => {
280280
expect(instance.user).toEqual(expect.objectContaining({ name: 'Default SuperDoc user', email: null }));
281281
});
282282

283+
it('scrolls to a comment and sets it active', async () => {
284+
const { commentsStore } = createAppHarness();
285+
const instance = new SuperDoc({
286+
selector: '#host',
287+
document: 'https://example.com/doc.docx',
288+
documents: [],
289+
modules: { comments: {}, toolbar: {} },
290+
colors: ['red'],
291+
user: { name: 'Jane', email: 'jane@example.com' },
292+
onException: vi.fn(),
293+
});
294+
await flushMicrotasks();
295+
296+
const target = document.createElement('div');
297+
target.setAttribute('data-comment-ids', 'comment-1');
298+
target.scrollIntoView = vi.fn();
299+
document.querySelector('#host').appendChild(target);
300+
301+
const result = instance.scrollToComment('comment-1');
302+
expect(result).toBe(true);
303+
expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
304+
expect(commentsStore.setActiveComment).toHaveBeenCalledWith(instance, 'comment-1');
305+
});
306+
307+
it('returns false when comment element is not found', async () => {
308+
createAppHarness();
309+
const instance = new SuperDoc({
310+
selector: '#host',
311+
document: 'https://example.com/doc.docx',
312+
documents: [],
313+
modules: { comments: {}, toolbar: {} },
314+
onException: vi.fn(),
315+
});
316+
await flushMicrotasks();
317+
318+
expect(instance.scrollToComment('nonexistent-id')).toBe(false);
319+
});
320+
283321
it('warns when both document object and documents list provided', async () => {
284322
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
285323
createAppHarness();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { test, expect } from '../../fixtures/superdoc.js';
2+
import { addCommentByText, assertDocumentApiReady } from '../../helpers/document-api.js';
3+
4+
test.use({ config: { toolbar: 'full', comments: 'on' } });
5+
6+
test('scrollToComment scrolls to the comment and activates it', async ({ superdoc }) => {
7+
await assertDocumentApiReady(superdoc.page);
8+
9+
// Create enough content so the comment is off-screen
10+
for (let i = 0; i < 30; i++) {
11+
await superdoc.type(`Line ${i}`);
12+
await superdoc.newLine();
13+
}
14+
await superdoc.type('target text');
15+
await superdoc.waitForStable();
16+
17+
const commentId = await addCommentByText(superdoc.page, {
18+
pattern: 'target text',
19+
text: 'scroll test comment',
20+
});
21+
await superdoc.waitForStable();
22+
await superdoc.assertCommentHighlightExists({ text: 'target text', timeoutMs: 20_000 });
23+
24+
// Scroll to the top so the comment is out of view
25+
await superdoc.page.evaluate(() => {
26+
document.querySelector('.superdoc')?.scrollTo({ top: 0 });
27+
});
28+
await superdoc.waitForStable();
29+
30+
// Call scrollToComment via the public API.
31+
// WebKit can lag on DOM attribute propagation, so poll until it succeeds.
32+
await expect
33+
.poll(async () => superdoc.page.evaluate((id) => (window as any).superdoc.scrollToComment(id), commentId), {
34+
timeout: 10_000,
35+
})
36+
.toBe(true);
37+
38+
// Verify the comment highlight is now visible in the viewport
39+
const highlight = superdoc.page.locator('.superdoc-comment-highlight').filter({ hasText: 'target text' });
40+
await expect(highlight.first()).toBeVisible({ timeout: 5_000 });
41+
});
42+
43+
test('scrollToComment returns false for a nonexistent comment', async ({ superdoc }) => {
44+
await assertDocumentApiReady(superdoc.page);
45+
46+
const result = await superdoc.page.evaluate(() => {
47+
return (window as any).superdoc.scrollToComment('nonexistent-id');
48+
});
49+
50+
expect(result).toBe(false);
51+
});

0 commit comments

Comments
 (0)