diff --git a/apps/docs/core/superdoc/methods.mdx b/apps/docs/core/superdoc/methods.mdx index 5f479d0643..a9e5030300 100644 --- a/apps/docs/core/superdoc/methods.mdx +++ b/apps/docs/core/superdoc/methods.mdx @@ -572,6 +572,61 @@ const superdoc = new SuperDoc({ +### `scrollToComment` + +Scroll to a comment in the document and set it as active. + + + The comment ID to scroll to + + + + Scroll behavior options + + + Scroll behavior — `"smooth"`, `"instant"`, or `"auto"` + + + Vertical alignment — `"start"`, `"center"`, `"end"`, or `"nearest"` + + + + +**Returns:** `boolean` — `true` if the comment was found, `false` otherwise. + + + +```javascript Usage +// Get a comment ID from the document API +const { items } = superdoc.editor.doc.comments.list(); +const commentId = items[0].id; + +// Scroll to it +superdoc.scrollToComment(commentId); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + modules: { comments: {} }, + onReady: (superdoc) => { + const { items } = superdoc.editor.doc.comments.list(); + if (items.length > 0) { + superdoc.scrollToComment(items[0].id, { + behavior: 'smooth', + block: 'center', + }); + } + }, +}); +``` + + + ## User management ### `addSharedUser` diff --git a/apps/docs/modules/comments.mdx b/apps/docs/modules/comments.mdx index 0ec62ecfb5..cf3d65fadf 100644 --- a/apps/docs/modules/comments.mdx +++ b/apps/docs/modules/comments.mdx @@ -546,6 +546,39 @@ const superdoc = new SuperDoc({ +### `scrollToComment` + +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. + + + +```javascript Usage +superdoc.scrollToComment("comment-123"); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + modules: { comments: {} }, + onReady: (superdoc) => { + const { items } = superdoc.editor.doc.comments.list(); + if (items.length > 0) { + superdoc.scrollToComment(items[0].id); + } + }, +}); +``` + + + + + See [SuperDoc Methods](/core/superdoc/methods#scrolltocomment) for full parameter documentation. + + ## Events ### `onCommentsUpdate` diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index c818272106..453dc4d773 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -1028,14 +1028,7 @@ watch(showCommentsSidebar, (value) => { * @param {String} commentId The commentId to scroll to */ const scrollToComment = (commentId) => { - const commentsConfig = proxy.$superdoc.config?.modules?.comments; - if (!commentsConfig || commentsConfig === false) return; - - const element = document.querySelector(`[data-thread-id=${commentId}]`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - commentsStore.setActiveComment(proxy.$superdoc, commentId); - } + proxy.$superdoc.scrollToComment(commentId); }; onMounted(() => { diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index e639728175..cb5d29c596 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -803,6 +803,29 @@ export class SuperDoc extends EventEmitter { } } + /** + * Scroll the document to a given comment by id. + * + * @param {string} commentId The comment id + * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] + * @returns {boolean} Whether a matching element was found + */ + scrollToComment(commentId, options = {}) { + const commentsConfig = this.config?.modules?.comments; + if (!commentsConfig || commentsConfig === false) return false; + if (!commentId || typeof commentId !== 'string') return false; + + const root = this.element || document; + const escaped = globalThis.CSS?.escape ? globalThis.CSS.escape(commentId) : commentId.replace(/"/g, '\\"'); + const element = root.querySelector(`[data-comment-ids*="${escaped}"]`); + if (!element) return false; + + const { behavior = 'smooth', block = 'start' } = options ?? {}; + element.scrollIntoView({ behavior, block }); + this.commentsStore?.setActiveComment?.(this, commentId); + return true; + } + /** * Toggle the custom context menu globally. * Updates both flow editors and PresentationEditor instances so downstream listeners can short-circuit early. diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 804f473317..784eef1021 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -280,6 +280,44 @@ describe('SuperDoc core', () => { expect(instance.user).toEqual(expect.objectContaining({ name: 'Default SuperDoc user', email: null })); }); + it('scrolls to a comment and sets it active', async () => { + const { commentsStore } = createAppHarness(); + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + const target = document.createElement('div'); + target.setAttribute('data-comment-ids', 'comment-1'); + target.scrollIntoView = vi.fn(); + document.querySelector('#host').appendChild(target); + + const result = instance.scrollToComment('comment-1'); + expect(result).toBe(true); + expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + expect(commentsStore.setActiveComment).toHaveBeenCalledWith(instance, 'comment-1'); + }); + + it('returns false when comment element is not found', async () => { + createAppHarness(); + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(instance.scrollToComment('nonexistent-id')).toBe(false); + }); + it('warns when both document object and documents list provided', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createAppHarness(); diff --git a/tests/behavior/tests/comments/scroll-to-comment.spec.ts b/tests/behavior/tests/comments/scroll-to-comment.spec.ts new file mode 100644 index 0000000000..933bb2c9ce --- /dev/null +++ b/tests/behavior/tests/comments/scroll-to-comment.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { addCommentByText, assertDocumentApiReady } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on' } }); + +test('scrollToComment scrolls to the comment and activates it', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + // Create enough content so the comment is off-screen + for (let i = 0; i < 30; i++) { + await superdoc.type(`Line ${i}`); + await superdoc.newLine(); + } + await superdoc.type('target text'); + await superdoc.waitForStable(); + + const commentId = await addCommentByText(superdoc.page, { + pattern: 'target text', + text: 'scroll test comment', + }); + await superdoc.waitForStable(); + await superdoc.assertCommentHighlightExists({ text: 'target text', timeoutMs: 20_000 }); + + // Scroll to the top so the comment is out of view + await superdoc.page.evaluate(() => { + document.querySelector('.superdoc')?.scrollTo({ top: 0 }); + }); + await superdoc.waitForStable(); + + // Call scrollToComment via the public API. + // WebKit can lag on DOM attribute propagation, so poll until it succeeds. + await expect + .poll(async () => superdoc.page.evaluate((id) => (window as any).superdoc.scrollToComment(id), commentId), { + timeout: 10_000, + }) + .toBe(true); + + // Verify the comment highlight is now visible in the viewport + const highlight = superdoc.page.locator('.superdoc-comment-highlight').filter({ hasText: 'target text' }); + await expect(highlight.first()).toBeVisible({ timeout: 5_000 }); +}); + +test('scrollToComment returns false for a nonexistent comment', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + const result = await superdoc.page.evaluate(() => { + return (window as any).superdoc.scrollToComment('nonexistent-id'); + }); + + expect(result).toBe(false); +});