From 615a5583b7be4b12fe73c7864f3401bcfb83b500 Mon Sep 17 00:00:00 2001 From: cam Date: Sun, 18 Jan 2026 23:28:04 -0800 Subject: [PATCH 1/7] feat(comments): add scrollToComment API --- packages/superdoc/src/core/SuperDoc.js | 21 ++++++++++++++++++ packages/superdoc/src/core/SuperDoc.test.js | 24 +++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index e639728175..15a430e3d7 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -803,6 +803,27 @@ export class SuperDoc extends EventEmitter { } } + /** + * Scroll the document to a given comment or tracked change by thread id. + * + * @param {string} commentId The comment or tracked change 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 element = document.querySelector(`[data-thread-id="${commentId}"]`); + 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..5abd2605f7 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -280,6 +280,30 @@ 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-thread-id', 'comment-1'); + target.scrollIntoView = vi.fn(); + document.body.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('warns when both document object and documents list provided', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createAppHarness(); From c004ae677965675063cd03967ae65923be6fca51 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 06:45:58 -0300 Subject: [PATCH 2/7] 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 --- packages/superdoc/src/core/SuperDoc.js | 4 +++- packages/superdoc/src/core/SuperDoc.test.js | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 15a430e3d7..ba4167fe54 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -815,7 +815,9 @@ export class SuperDoc extends EventEmitter { if (!commentsConfig || commentsConfig === false) return false; if (!commentId || typeof commentId !== 'string') return false; - const element = document.querySelector(`[data-thread-id="${commentId}"]`); + const root = this.element || document; + const escaped = globalThis.CSS?.escape ? globalThis.CSS.escape(commentId) : commentId.replace(/"/g, '\\"'); + const element = root.querySelector(`[data-thread-id="${escaped}"]`); if (!element) return false; const { behavior = 'smooth', block = 'start' } = options; diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 5abd2605f7..b9c5b62b8e 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -296,7 +296,7 @@ describe('SuperDoc core', () => { const target = document.createElement('div'); target.setAttribute('data-thread-id', 'comment-1'); target.scrollIntoView = vi.fn(); - document.body.appendChild(target); + document.querySelector('#host').appendChild(target); const result = instance.scrollToComment('comment-1'); expect(result).toBe(true); @@ -304,6 +304,20 @@ describe('SuperDoc core', () => { 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(); From 2e42999259729278514d6123b74df343f4e0f044 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 06:57:07 -0300 Subject: [PATCH 3/7] 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 --- packages/superdoc/src/SuperDoc.vue | 9 +-------- packages/superdoc/src/core/SuperDoc.js | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) 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 ba4167fe54..7708b16c10 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -820,7 +820,7 @@ export class SuperDoc extends EventEmitter { const element = root.querySelector(`[data-thread-id="${escaped}"]`); if (!element) return false; - const { behavior = 'smooth', block = 'start' } = options; + const { behavior = 'smooth', block = 'start' } = options ?? {}; element.scrollIntoView({ behavior, block }); this.commentsStore?.setActiveComment?.(this, commentId); return true; From 58496473ce57e0c13de043bd06ef9ba7334d8ad1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 07:03:18 -0300 Subject: [PATCH 4/7] test(comments): add behavior test for scrollToComment API --- .../tests/comments/scroll-to-comment.spec.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/behavior/tests/comments/scroll-to-comment.spec.ts 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..24d6b182ee --- /dev/null +++ b/tests/behavior/tests/comments/scroll-to-comment.spec.ts @@ -0,0 +1,50 @@ +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 + const result = await superdoc.page.evaluate((id) => { + return (window as any).superdoc.scrollToComment(id); + }, commentId); + + expect(result).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); +}); From 0ee9d5743997aa7eb0982730922f002bad4ec65c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 07:09:06 -0300 Subject: [PATCH 5/7] 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). --- packages/superdoc/src/core/SuperDoc.js | 6 +++--- packages/superdoc/src/core/SuperDoc.test.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 7708b16c10..cb5d29c596 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -804,9 +804,9 @@ export class SuperDoc extends EventEmitter { } /** - * Scroll the document to a given comment or tracked change by thread id. + * Scroll the document to a given comment by id. * - * @param {string} commentId The comment or tracked change id + * @param {string} commentId The comment id * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] * @returns {boolean} Whether a matching element was found */ @@ -817,7 +817,7 @@ export class SuperDoc extends EventEmitter { const root = this.element || document; const escaped = globalThis.CSS?.escape ? globalThis.CSS.escape(commentId) : commentId.replace(/"/g, '\\"'); - const element = root.querySelector(`[data-thread-id="${escaped}"]`); + const element = root.querySelector(`[data-comment-ids*="${escaped}"]`); if (!element) return false; const { behavior = 'smooth', block = 'start' } = options ?? {}; diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index b9c5b62b8e..784eef1021 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -294,7 +294,7 @@ describe('SuperDoc core', () => { await flushMicrotasks(); const target = document.createElement('div'); - target.setAttribute('data-thread-id', 'comment-1'); + target.setAttribute('data-comment-ids', 'comment-1'); target.scrollIntoView = vi.fn(); document.querySelector('#host').appendChild(target); From 5d8a48eed12e3898103c7b6d4047d06fe5241a22 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 07:14:25 -0300 Subject: [PATCH 6/7] fix(test): poll scrollToComment in behavior test for WebKit timing --- .../tests/comments/scroll-to-comment.spec.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/behavior/tests/comments/scroll-to-comment.spec.ts b/tests/behavior/tests/comments/scroll-to-comment.spec.ts index 24d6b182ee..933bb2c9ce 100644 --- a/tests/behavior/tests/comments/scroll-to-comment.spec.ts +++ b/tests/behavior/tests/comments/scroll-to-comment.spec.ts @@ -27,12 +27,13 @@ test('scrollToComment scrolls to the comment and activates it', async ({ superdo }); await superdoc.waitForStable(); - // Call scrollToComment via the public API - const result = await superdoc.page.evaluate((id) => { - return (window as any).superdoc.scrollToComment(id); - }, commentId); - - expect(result).toBe(true); + // 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' }); From e91c9b103174e9b73fabadcdf030256e51695317 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 07:17:34 -0300 Subject: [PATCH 7/7] docs(comments): add scrollToComment API documentation --- apps/docs/core/superdoc/methods.mdx | 55 +++++++++++++++++++++++++++++ apps/docs/modules/comments.mdx | 33 +++++++++++++++++ 2 files changed, 88 insertions(+) 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`