diff --git a/frontend/src/lib/utils/deepLinkScroll.spec.ts b/frontend/src/lib/utils/deepLinkScroll.spec.ts new file mode 100644 index 00000000..20f0cf19 --- /dev/null +++ b/frontend/src/lib/utils/deepLinkScroll.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest'; +import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll'; + +const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111'; +const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222'; + +function fakeElement() { + return { + scrollIntoView: vi.fn(), + focus: vi.fn() + } as unknown as HTMLElement; +} + +type Overrides = Partial; + +function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions { + const el = overrides.getElement ? null : fakeElement(); + return { + transcribeMode: true, + setTranscribeMode: vi.fn(), + loadBlocks: vi.fn().mockResolvedValue(undefined), + setActiveAnnotationId: vi.fn(), + flashAnnotation: vi.fn(), + prefersReducedMotion: false, + afterTick: vi.fn().mockResolvedValue(undefined), + getElement: vi.fn().mockReturnValue(el), + onStripUrl: vi.fn(), + ...overrides + }; +} + +describe('scrollToCommentFromQuery', () => { + it('is a no-op when commentId query param is absent', async () => { + const url = new URL('https://app/documents/doc-1'); + const opts = buildOpts(); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.setActiveAnnotationId).not.toHaveBeenCalled(); + expect(opts.getElement).not.toHaveBeenCalled(); + expect(opts.onStripUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when annotationId query param is absent even if commentId is present', async () => { + const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`); + const opts = buildOpts(); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.setActiveAnnotationId).not.toHaveBeenCalled(); + expect(opts.getElement).not.toHaveBeenCalled(); + }); + + it('scrolls to the comment element and focuses it when both params are present', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const el = fakeElement(); + const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) }); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`); + expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + expect(el.focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('triggers the annotation flash after scrolling', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const el = fakeElement(); + const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) }); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID); + }); + + it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const opts = buildOpts({ transcribeMode: false }); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.setTranscribeMode).toHaveBeenCalledWith(true); + expect(opts.loadBlocks).toHaveBeenCalled(); + }); + + it('is a graceful no-op when the target element is not in the DOM', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) }); + + // Must not throw. Flash should not fire — nothing to highlight. + await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined(); + + expect(opts.flashAnnotation).not.toHaveBeenCalled(); + }); + + it('uses behavior "instant" when prefers-reduced-motion is set', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const el = fakeElement(); + const opts = buildOpts({ + prefersReducedMotion: true, + getElement: vi.fn().mockReturnValue(el) + }); + + await scrollToCommentFromQuery(url, opts); + + expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' }); + }); + + it('strips both commentId and annotationId from the URL after handling', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const opts = buildOpts(); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.onStripUrl).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/utils/deepLinkScroll.ts b/frontend/src/lib/utils/deepLinkScroll.ts new file mode 100644 index 00000000..cc0d49c9 --- /dev/null +++ b/frontend/src/lib/utils/deepLinkScroll.ts @@ -0,0 +1,40 @@ +export type DeepLinkScrollOptions = { + transcribeMode: boolean; + setTranscribeMode: (value: boolean) => void; + loadBlocks: () => Promise; + setActiveAnnotationId: (id: string) => void; + flashAnnotation: (annotationId: string) => void; + prefersReducedMotion: boolean; + afterTick: () => Promise; + getElement: (id: string) => HTMLElement | null; + onStripUrl: () => void; +}; + +export async function scrollToCommentFromQuery( + url: URL, + opts: DeepLinkScrollOptions +): Promise { + const commentId = url.searchParams.get('commentId'); + if (!commentId) return; + + const annotationId = url.searchParams.get('annotationId'); + if (!annotationId) return; + + if (!opts.transcribeMode) { + opts.setTranscribeMode(true); + await opts.loadBlocks(); + } + + opts.setActiveAnnotationId(annotationId); + await opts.afterTick(); + + const el = opts.getElement(`comment-${commentId}`); + if (el) { + const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth'; + el.scrollIntoView({ behavior, block: 'center' }); + el.focus({ preventScroll: true }); + opts.flashAnnotation(annotationId); + } + + opts.onStripUrl(); +}