fix: pdf viewer scroll elements

This commit is contained in:
David Nguyen
2026-02-06 14:59:52 +11:00
parent cb6d6e46d0
commit ab3e8a4074
17 changed files with 183 additions and 28 deletions
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -38,6 +40,8 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
{
@@ -95,12 +99,13 @@ export const DocumentDuplicateDialog = ({
</h1>
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<div ref={scrollContainerRef} className="h-[50vh] overflow-y-scroll p-2">
<PDFViewer
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="initial"
scrollParentRef={scrollContainerRef}
/>
</div>
)}
@@ -551,6 +551,7 @@ export const ConfigureFieldsView = ({
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="current"
scrollParentRef="window"
/>
<ElementVisible
@@ -345,6 +345,7 @@ export const EmbedDirectTemplateClientPage = ({
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="current"
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -291,6 +291,7 @@ export const EmbedSignDocumentV1ClientPage = ({
envelopeItem={envelopeItems[0]}
token={token}
version="current"
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -66,6 +66,8 @@ export const MultiSignDocumentSigningView = ({
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isExpanded, setIsExpanded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
@@ -179,7 +181,11 @@ export const MultiSignDocumentSigningView = ({
return (
<div className="min-h-screen overflow-hidden bg-background">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
<div
id="document-field-portal-root"
ref={scrollContainerRef}
className="relative h-full w-full overflow-y-auto p-8"
>
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
@@ -230,6 +236,7 @@ export const MultiSignDocumentSigningView = ({
envelopeItem={document.envelopeItems[0]}
token={token}
version="current"
scrollParentRef={scrollContainerRef}
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();
@@ -156,6 +156,7 @@ export const DirectTemplatePageView = ({
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
version="current"
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -279,6 +279,7 @@ export const DocumentSigningPageViewV1 = ({
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="current"
scrollParentRef="window"
/>
</CardContent>
</Card>
@@ -1,4 +1,4 @@
import { lazy, useMemo } from 'react';
import { lazy, useMemo, useRef } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
@@ -41,6 +41,8 @@ const EnvelopeSignerPageRenderer = lazy(
export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const {
isDirectTemplate,
envelope,
@@ -200,7 +202,10 @@ export const DocumentSigningPageViewV2 = () => {
</div>
</div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div
className="embed--DocumentContainer flex-1 overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -232,6 +237,7 @@ export const DocumentSigningPageViewV2 = () => {
<EnvelopePdfViewer
key={currentEnvelopeItem.id}
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
/>
) : (
@@ -1,4 +1,4 @@
import { lazy, useEffect, useState } from 'react';
import { lazy, useEffect, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
@@ -157,6 +157,7 @@ export const DocumentCertificateQRView = ({
envelopeItem={envelopeItems[0]}
token={token}
version="current"
scrollParentRef="window"
/>
</div>
</>
@@ -180,6 +181,8 @@ const DocumentCertificateQrV2 = ({
}: DocumentCertificateQrV2Props) => {
const { envelopeItems } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
return (
<div className="flex min-h-screen flex-col items-start">
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
@@ -210,11 +213,12 @@ const DocumentCertificateQrV2 = ({
/>
</div>
<div className="mt-12 w-full">
<div className="mt-12 max-h-[80vh] w-full overflow-y-auto" ref={scrollableContainerRef}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</div>
@@ -445,6 +445,7 @@ export const DocumentEditForm = ({
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="current"
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { lazy, useEffect, useMemo, useRef, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
@@ -34,6 +34,8 @@ export const EnvelopeEditorPreviewPage = () => {
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
@@ -214,7 +216,7 @@ export const EnvelopeEditorPreviewPage = () => {
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div className="flex w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
@@ -232,6 +234,7 @@ export const EnvelopeEditorPreviewPage = () => {
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
) : (
@@ -317,6 +317,7 @@ export const TemplateEditForm = ({
envelopeItem={template.envelopeItems[0]}
token={undefined}
version="current"
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -173,6 +173,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<CardContent className="p-2">
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef="window"
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</CardContent>
@@ -200,6 +201,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
token={undefined}
key={envelope.envelopeItems[0].id}
version="current"
scrollParentRef="window"
/>
</CardContent>
</Card>
@@ -191,6 +191,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<CardContent className="p-2">
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef="window"
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</CardContent>
@@ -217,6 +218,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
token={undefined}
version="current"
key={envelope.envelopeItems[0].id}
scrollParentRef="window"
/>
</CardContent>
</Card>
@@ -13,12 +13,22 @@ import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
export type EnvelopePdfViewerProps = {
className?: string;
scrollParentRef?: React.RefObject<HTMLDivElement>;
/**
* Ref to the scrollable parent container that handles scrolling.
*
* This must point to an element with `overflow-y: auto` or `overflow-y: scroll`
* that is an ancestor of this component, or `'window'` to use the browser
* window as the scroll container.
*/
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
/**
@@ -101,7 +111,7 @@ export const EnvelopePdfViewer = ({
currentItemMeta &&
numPages > 0 && (
<VirtualizedPageList
scrollParentRef={scrollParentRef ?? $el}
scrollParentRef={scrollParentRef}
constraintRef={$el}
pages={currentItemMeta}
envelopeItemId={currentEnvelopeItem.id}
@@ -127,7 +137,7 @@ export const EnvelopePdfViewer = ({
};
type VirtualizedPageListProps = {
scrollParentRef: React.RefObject<HTMLDivElement>;
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement>;
pages: BasePageRenderData[];
envelopeItemId: string;
@@ -144,9 +154,12 @@ const VirtualizedPageList = ({
numPages,
CustomPageRenderer,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const { virtualItems, totalSize, constraintWidth } = useVirtualList({
scrollRef: scrollParentRef,
constraintRef,
contentRef,
itemCount: numPages,
itemSize: (index, width) => {
const pageMeta = pages[index];
@@ -163,6 +176,7 @@ const VirtualizedPageList = ({
return (
<div
ref={contentRef}
style={{
height: `${totalSize}px`,
width: '100%',
@@ -15,6 +15,7 @@ import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/
import { cn } from '../../lib/utils';
import { useToast } from '../../primitives/use-toast';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
@@ -49,6 +50,16 @@ export type PDFViewerProps = {
token: string | undefined;
presignToken?: string | undefined;
version: DocumentDataVersion;
/**
* Ref to the scrollable parent container that handles scrolling.
*
* This must point to an element with `overflow-y: auto` or `overflow-y: scroll`
* that is an ancestor of this component, or `'window'` to use the browser
* window as the scroll container.
*/
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
overrideImages?: OverrideImage[];
} & React.HTMLAttributes<HTMLDivElement>;
@@ -59,6 +70,7 @@ export const PDFViewer = ({
token,
presignToken,
version,
scrollParentRef,
onDocumentLoad,
overrideImages,
...props
@@ -168,7 +180,7 @@ export const PDFViewer = ({
const hasError = loadingState === 'error';
return (
<div ref={$el} className={cn('h-full w-full overflow-hidden', className)} {...props}>
<div ref={$el} className={cn('w-full', className)} {...props}>
{/* Loading State */}
{isLoading && <PdfViewerLoadingState />}
@@ -178,7 +190,7 @@ export const PDFViewer = ({
{/* Loaded State */}
{loadingState === 'loaded' && numPages > 0 && (
<VirtualizedPageList
scrollParentRef={$el}
scrollParentRef={scrollParentRef}
constraintRef={$el}
numPages={numPages}
pages={derivedPages}
@@ -189,7 +201,7 @@ export const PDFViewer = ({
};
type VirtualizedPageListProps = {
scrollParentRef: React.RefObject<HTMLDivElement>;
scrollParentRef: ScrollTarget;
constraintRef: React.RefObject<HTMLDivElement>;
pages: PageMeta[];
numPages: number;
@@ -203,9 +215,12 @@ const VirtualizedPageList = ({
pages,
numPages,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const { virtualItems, totalSize, constraintWidth } = useVirtualList({
scrollRef: scrollParentRef,
constraintRef,
contentRef,
itemCount: numPages,
itemSize: (index, width) => {
const pageMeta = pages[index];
@@ -222,6 +237,7 @@ const VirtualizedPageList = ({
return (
<div
ref={contentRef}
style={{
height: `${totalSize}px`,
width: '100%',
@@ -1,8 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export type ScrollTarget = React.RefObject<HTMLElement | null> | 'window';
export type VirtualListOptions = {
scrollRef: React.RefObject<HTMLElement | null>;
scrollRef: ScrollTarget;
constraintRef?: React.RefObject<HTMLElement | null>;
/**
* Ref to the element that contains the virtual list content.
*
* Used to calculate the offset between the scroll container and the virtual
* list when the scroll container is a parent element higher in the DOM tree.
*
* When the virtual list is not at the top of the scroll container (e.g. there
* are headers, alerts, or other content above it), this offset ensures the
* scroll position is correctly adjusted for virtualization calculations.
*/
contentRef?: React.RefObject<HTMLElement | null>;
itemCount: number;
itemSize: number | ((index: number, constraintWidth: number) => number);
overscan?: number;
@@ -28,12 +43,20 @@ export type VirtualListResult = {
* @returns Virtual items to render, total size, and constraint width
*/
export const useVirtualList = (options: VirtualListOptions): VirtualListResult => {
const { scrollRef, constraintRef, itemCount, itemSize, overscan = 3 } = options;
const { scrollRef, constraintRef, contentRef, itemCount, itemSize, overscan = 3 } = options;
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [constraintWidth, setConstraintWidth] = useState(0);
/**
* The offset of the content element relative to the scroll container.
*
* This is recalculated on scroll to handle cases where dynamic content
* above the virtual list changes size.
*/
const contentOffsetRef = useRef(0);
// Track constraint element width with ResizeObserver
useEffect(() => {
const el = constraintRef?.current;
@@ -60,6 +83,19 @@ export const useVirtualList = (options: VirtualListOptions): VirtualListResult =
// Track scroll container dimensions with ResizeObserver
useEffect(() => {
if (scrollRef === 'window') {
const handleResize = () => {
setViewportHeight(window.innerHeight);
};
window.addEventListener('resize', handleResize);
// Set initial height
setViewportHeight(window.innerHeight);
return () => window.removeEventListener('resize', handleResize);
}
const el = scrollRef.current;
if (!el) {
@@ -82,25 +118,78 @@ export const useVirtualList = (options: VirtualListOptions): VirtualListResult =
return () => observer.disconnect();
}, [scrollRef]);
// Handle scroll events
// Handle scroll events and calculate content offset
useEffect(() => {
const el = scrollRef.current;
if (scrollRef === 'window') {
const calculateOffset = () => {
const contentEl = contentRef?.current;
if (!el) {
if (!contentEl) {
contentOffsetRef.current = 0;
return;
}
// For window scrolling, the offset is the distance from the top of the
// content element to the top of the document, which is its bounding rect
// top plus the current scroll position.
contentOffsetRef.current = contentEl.getBoundingClientRect().top + window.scrollY;
};
const handleScroll = () => {
calculateOffset();
const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
};
window.addEventListener('scroll', handleScroll, { passive: true });
// Set initial values
calculateOffset();
const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
return () => window.removeEventListener('scroll', handleScroll);
}
const scrollEl = scrollRef.current;
if (!scrollEl) {
return;
}
const handleScroll = () => {
setScrollTop(el.scrollTop);
const calculateOffset = () => {
const contentEl = contentRef?.current;
if (!contentEl) {
contentOffsetRef.current = 0;
return;
}
const scrollRect = scrollEl.getBoundingClientRect();
const contentRect = contentEl.getBoundingClientRect();
// The offset is the distance from the top of the content element to
// the top of the scroll container, adjusted for current scroll position.
contentOffsetRef.current = contentRect.top - scrollRect.top + scrollEl.scrollTop;
};
el.addEventListener('scroll', handleScroll, { passive: true });
const handleScroll = () => {
calculateOffset();
// Set initial scroll position
setScrollTop(el.scrollTop);
const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
};
return () => el.removeEventListener('scroll', handleScroll);
}, [scrollRef]);
scrollEl.addEventListener('scroll', handleScroll, { passive: true });
// Set initial values
calculateOffset();
const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current);
setScrollTop(adjustedScrollTop);
return () => scrollEl.removeEventListener('scroll', handleScroll);
}, [scrollRef, contentRef]);
// Get item size helper
const getItemSize = useCallback(