diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 103c3af1e..e90821286 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -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(null); + const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = trpcReact.envelope.item.getManyByToken.useQuery( { @@ -95,12 +99,13 @@ export const DocumentDuplicateDialog = ({ ) : ( -
+
)} diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 6f13d713a..5b9e44d5e 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -551,6 +551,7 @@ export const ConfigureFieldsView = ({ envelopeItem={normalizedEnvelopeItem} token={undefined} version="current" + scrollParentRef="window" /> setHasDocumentLoaded(true)} />
diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 00924988e..befa0620a 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -291,6 +291,7 @@ export const EmbedSignDocumentV1ClientPage = ({ envelopeItem={envelopeItems[0]} token={token} version="current" + scrollParentRef="window" onDocumentLoad={() => setHasDocumentLoaded(true)} /> diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 34832620b..39087b77d 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -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(null); + const [isExpanded, setIsExpanded] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); @@ -179,7 +181,11 @@ export const MultiSignDocumentSigningView = ({ return (
-
+
{match({ isLoading, document }) .with({ isLoading: true }, () => (
@@ -230,6 +236,7 @@ export const MultiSignDocumentSigningView = ({ envelopeItem={document.envelopeItems[0]} token={token} version="current" + scrollParentRef={scrollContainerRef} onDocumentLoad={() => { setHasDocumentLoaded(true); onDocumentReady?.(); diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 3a6ade955..66668452e 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -156,6 +156,7 @@ export const DirectTemplatePageView = ({ envelopeItem={template.envelopeItems[0]} token={directTemplateRecipient.token} version="current" + scrollParentRef="window" onDocumentLoad={() => setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 55f926e16..a39253ef8 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -279,6 +279,7 @@ export const DocumentSigningPageViewV1 = ({ envelopeItem={document.envelopeItems[0]} token={recipient.token} version="current" + scrollParentRef="window" /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx index 6f10f1f76..514a1d154 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx @@ -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(null); + const { isDirectTemplate, envelope, @@ -200,7 +202,10 @@ export const DocumentSigningPageViewV2 = () => {
-
+
{/* Horizontal envelope item selector */} {envelopeItems.length > 1 && ( @@ -232,6 +237,7 @@ export const DocumentSigningPageViewV2 = () => { ) : ( diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index 7365797ce..1213319bb 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -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" />
@@ -180,6 +181,8 @@ const DocumentCertificateQrV2 = ({ }: DocumentCertificateQrV2Props) => { const { envelopeItems } = useCurrentEnvelopeRender(); + const scrollableContainerRef = useRef(null); + return (
@@ -210,11 +213,12 @@ const DocumentCertificateQrV2 = ({ />
-
+
diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index faacbcf39..2c3ac5f2b 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -445,6 +445,7 @@ export const DocumentEditForm = ({ envelopeItem={document.envelopeItems[0]} token={undefined} version="current" + scrollParentRef="window" onDocumentLoad={() => setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 89ee0c785..9b89e979a 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -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(null); + const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>( 'recipient', ); @@ -214,7 +216,7 @@ export const EnvelopeEditorPreviewPage = () => { }} >
-
+
{/* Horizontal envelope item selector */} @@ -232,6 +234,7 @@ export const EnvelopeEditorPreviewPage = () => { {currentEnvelopeItem !== null ? ( ) : ( diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 18d9551b2..a3a482bf0 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -317,6 +317,7 @@ export const TemplateEditForm = ({ envelopeItem={template.envelopeItems[0]} token={undefined} version="current" + scrollParentRef="window" onDocumentLoad={() => setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index 75956777d..50f09bc01 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -173,6 +173,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) { @@ -200,6 +201,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) { token={undefined} key={envelope.envelopeItems[0].id} version="current" + scrollParentRef="window" /> diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx index 68cf8eff2..a29251ffe 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx @@ -191,6 +191,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) { @@ -217,6 +218,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) { token={undefined} version="current" key={envelope.envelopeItems[0].id} + scrollParentRef="window" /> diff --git a/packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx b/packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx index 6758072c2..e4c5a4f16 100644 --- a/packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx +++ b/packages/ui/components/pdf-viewer/envelope-pdf-viewer.tsx @@ -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; + + /** + * 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 && ( ; + scrollParentRef: ScrollTarget; constraintRef: React.RefObject; pages: BasePageRenderData[]; envelopeItemId: string; @@ -144,9 +154,12 @@ const VirtualizedPageList = ({ numPages, CustomPageRenderer, }: VirtualizedPageListProps) => { + const contentRef = useRef(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 (
void; overrideImages?: OverrideImage[]; } & React.HTMLAttributes; @@ -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 ( -
+
{/* Loading State */} {isLoading && } @@ -178,7 +190,7 @@ export const PDFViewer = ({ {/* Loaded State */} {loadingState === 'loaded' && numPages > 0 && ( ; + scrollParentRef: ScrollTarget; constraintRef: React.RefObject; pages: PageMeta[]; numPages: number; @@ -203,9 +215,12 @@ const VirtualizedPageList = ({ pages, numPages, }: VirtualizedPageListProps) => { + const contentRef = useRef(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 (
| 'window'; export type VirtualListOptions = { - scrollRef: React.RefObject; + scrollRef: ScrollTarget; constraintRef?: React.RefObject; + + /** + * 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; + 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(