mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 00:43:40 +10:00
feat: add pdf image renderer
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import type {
|
||||
BasePageRenderData,
|
||||
PageRenderData,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
|
||||
import { useVirtualList } from '../virtual-list/use-virtual-list';
|
||||
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
|
||||
|
||||
export type EnvelopePdfViewerProps = {
|
||||
className?: string;
|
||||
scrollParentRef?: React.RefObject<HTMLDivElement>;
|
||||
onDocumentLoad?: () => void;
|
||||
|
||||
/**
|
||||
* Custom page renderer to use instead of just rendering the page as an image.
|
||||
*
|
||||
* Mainly used for when you want to render the page with Konva.
|
||||
*/
|
||||
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||
|
||||
/**
|
||||
* The error message to render when there is an error.
|
||||
*/
|
||||
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvelopePdfViewer = ({
|
||||
className,
|
||||
scrollParentRef,
|
||||
onDocumentLoad,
|
||||
customPageRenderer: CustomPageRenderer,
|
||||
errorMessage,
|
||||
...props
|
||||
}: EnvelopePdfViewerProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { currentEnvelopeItem, envelopeItemsMeta, envelopeItemsMetaLoadingState, renderError } =
|
||||
useCurrentEnvelopeRender();
|
||||
|
||||
/**
|
||||
* The metadata for the current item.
|
||||
*
|
||||
* `null` if no current item is selected.
|
||||
*/
|
||||
const currentItemMeta = useMemo(() => {
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return envelopeItemsMeta[currentEnvelopeItem.id] ?? null;
|
||||
}, [currentEnvelopeItem, envelopeItemsMeta]);
|
||||
|
||||
const numPages = currentItemMeta?.length ?? 0;
|
||||
|
||||
/**
|
||||
* Trigger the onDocumentLoad callback when the document is loaded.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (envelopeItemsMetaLoadingState === 'loaded' && onDocumentLoad) {
|
||||
onDocumentLoad();
|
||||
}
|
||||
}, [envelopeItemsMetaLoadingState, onDocumentLoad]);
|
||||
|
||||
const isLoading = envelopeItemsMetaLoadingState === 'loading';
|
||||
const hasError = envelopeItemsMetaLoadingState === 'error';
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('h-full w-full max-w-[800px]', className)} {...props}>
|
||||
{renderError && (
|
||||
<Alert variant="destructive" className="mb-4 max-w-[800px]">
|
||||
<AlertTitle>
|
||||
{t(errorMessage?.title || PDF_VIEWER_ERROR_MESSAGES.default.title)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(errorMessage?.description || PDF_VIEWER_ERROR_MESSAGES.default.description)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && <PdfViewerLoadingState />}
|
||||
|
||||
{/* Error State */}
|
||||
{hasError && <PdfViewerErrorState />}
|
||||
|
||||
{/* Render pages in a virtualized list. */}
|
||||
{envelopeItemsMetaLoadingState === 'loaded' &&
|
||||
currentEnvelopeItem &&
|
||||
currentItemMeta &&
|
||||
numPages > 0 && (
|
||||
<VirtualizedPageList
|
||||
scrollParentRef={scrollParentRef ?? $el}
|
||||
constraintRef={$el}
|
||||
pages={currentItemMeta}
|
||||
envelopeItemId={currentEnvelopeItem.id}
|
||||
numPages={numPages}
|
||||
CustomPageRenderer={CustomPageRenderer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No current item selected */}
|
||||
{envelopeItemsMetaLoadingState === 'loaded' && !currentEnvelopeItem && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>No document selected</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualizedPageListProps = {
|
||||
scrollParentRef: React.RefObject<HTMLDivElement>;
|
||||
constraintRef: React.RefObject<HTMLDivElement>;
|
||||
pages: BasePageRenderData[];
|
||||
envelopeItemId: string;
|
||||
numPages: number;
|
||||
CustomPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||
};
|
||||
|
||||
// Note: There is a duplicate of this component in `PDFViewer`.
|
||||
const VirtualizedPageList = ({
|
||||
scrollParentRef,
|
||||
constraintRef,
|
||||
pages,
|
||||
envelopeItemId,
|
||||
numPages,
|
||||
CustomPageRenderer,
|
||||
}: VirtualizedPageListProps) => {
|
||||
const { virtualItems, totalSize, constraintWidth } = useVirtualList({
|
||||
scrollRef: scrollParentRef,
|
||||
constraintRef,
|
||||
itemCount: numPages,
|
||||
itemSize: (index, width) => {
|
||||
const pageMeta = pages[index];
|
||||
|
||||
// Calculate height based on aspect ratio and available width
|
||||
const aspectRatio = pageMeta.pageHeight / pageMeta.pageWidth;
|
||||
const scaledHeight = width * aspectRatio;
|
||||
|
||||
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
|
||||
return scaledHeight + 32;
|
||||
},
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const index = virtualItem.index;
|
||||
const pageMeta = pages[index];
|
||||
const pageNumber = index + 1;
|
||||
|
||||
// Calculate scale based on constraint width
|
||||
const scale = constraintWidth / pageMeta.pageWidth;
|
||||
|
||||
const pageData: PageRenderData = {
|
||||
...pageMeta,
|
||||
scale,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={envelopeItemId + '-' + virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: constraintWidth,
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="rounded border border-border">
|
||||
{CustomPageRenderer ? (
|
||||
<CustomPageRenderer pageData={pageData} />
|
||||
) : (
|
||||
<ImagePageRenderer pageData={pageData} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {pageNumber} of {numPages}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData;
|
||||
|
||||
const scaledWidth = pageWidth * scale;
|
||||
const scaledHeight = pageHeight * scale;
|
||||
|
||||
return (
|
||||
<div className="relative w-full" style={{ width: scaledWidth, height: scaledHeight }}>
|
||||
<img
|
||||
data-page-number={pageNumber}
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className={cn(PDF_VIEWER_PAGE_CLASSNAME, 'absolute inset-0 z-0 block')}
|
||||
style={{
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
}}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvelopePdfViewer;
|
||||
@@ -1,26 +0,0 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
|
||||
import { type PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
import type { PdfViewerRendererMode } from './pdf-viewer-konva';
|
||||
|
||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
onDocumentLoad?: () => void;
|
||||
renderer: PdfViewerRendererMode;
|
||||
[key: string]: unknown;
|
||||
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
|
||||
|
||||
const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
|
||||
|
||||
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<EnvelopePdfViewer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default PDFViewerKonvaLazy;
|
||||
@@ -1,213 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import Konva from 'konva';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { type PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
|
||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
|
||||
/**
|
||||
* This imports the worker from the `pdfjs-dist` package.
|
||||
*/
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const pdfViewerOptions = {
|
||||
cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`,
|
||||
};
|
||||
|
||||
const PDFLoader = () => (
|
||||
<>
|
||||
<Loader className="h-12 w-12 animate-spin text-documenso" />
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
<Trans>Loading document...</Trans>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing';
|
||||
|
||||
const RendererErrorMessages: Record<
|
||||
PdfViewerRendererMode,
|
||||
{ title: MessageDescriptor; description: MessageDescriptor }
|
||||
> = {
|
||||
editor: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
|
||||
},
|
||||
preview: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
|
||||
},
|
||||
signing: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
|
||||
},
|
||||
};
|
||||
|
||||
export type PdfViewerKonvaProps = {
|
||||
className?: string;
|
||||
onDocumentLoad?: () => void;
|
||||
customPageRenderer?: React.FunctionComponent;
|
||||
renderer: PdfViewerRendererMode;
|
||||
[key: string]: unknown;
|
||||
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
|
||||
|
||||
export const PdfViewerKonva = ({
|
||||
className,
|
||||
onDocumentLoad,
|
||||
customPageRenderer,
|
||||
renderer,
|
||||
...props
|
||||
}: PdfViewerKonvaProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
const envelopeItemFile = useMemo(() => {
|
||||
const data = getPdfBuffer(currentEnvelopeItem?.id || '');
|
||||
|
||||
if (!data || data.status !== 'loaded') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: new Uint8Array(data.file),
|
||||
};
|
||||
}, [currentEnvelopeItem?.id, getPdfBuffer]);
|
||||
|
||||
const onDocumentLoaded = useCallback(
|
||||
(doc: PDFDocumentProxy) => {
|
||||
setNumPages(doc.numPages);
|
||||
},
|
||||
[onDocumentLoad],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
const $current = $el.current;
|
||||
|
||||
const { width } = $current.getBoundingClientRect();
|
||||
|
||||
setWidth(width);
|
||||
|
||||
const onResize = () => {
|
||||
const { width } = $current.getBoundingClientRect();
|
||||
setWidth(width);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
|
||||
{renderError && (
|
||||
<Alert variant="destructive" className="mb-4 max-w-[800px]">
|
||||
<AlertTitle>{t(RendererErrorMessages[renderer].title)}</AlertTitle>
|
||||
<AlertDescription>{t(RendererErrorMessages[renderer].description)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{envelopeItemFile && Konva ? (
|
||||
<PDFDocument
|
||||
file={envelopeItemFile}
|
||||
className={cn('w-full rounded', {
|
||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||
})}
|
||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
|
||||
// Therefore we add some additional custom error handling.
|
||||
onSourceError={() => {
|
||||
setPdfError(true);
|
||||
}}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
|
||||
{pdfError ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>
|
||||
<Trans>Something went wrong while loading the document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
<Trans>Please try again or contact our support.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<PDFLoader />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>
|
||||
<Trans>Something went wrong while loading the document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
<Trans>Please try again or contact our support.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
options={pdfViewerOptions}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="last:-mb-2">
|
||||
<div className="rounded border border-border will-change-transform">
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
renderMode={customPageRenderer ? 'custom' : 'canvas'}
|
||||
customRenderer={customPageRenderer}
|
||||
/>
|
||||
</div>
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {i + 1} of {numPages}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
|
||||
)}
|
||||
>
|
||||
<PDFLoader />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PdfViewerKonva;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
export const PdfViewerLoadingState = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PdfViewerErrorState = () => {
|
||||
return (
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50 dark:bg-background">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>
|
||||
<Trans>Something went wrong while loading the document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
<Trans>Please try again or contact our support.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document-data';
|
||||
import {
|
||||
getEnvelopeItemMetaUrl,
|
||||
getEnvelopeItemPageImageUrl,
|
||||
} from '@documenso/lib/utils/envelope-images';
|
||||
import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useToast } from '../../primitives/use-toast';
|
||||
import { useVirtualList } from '../virtual-list/use-virtual-list';
|
||||
import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states';
|
||||
|
||||
export type OverrideImage = {
|
||||
image: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type OnPDFViewerPageClick = (_event: {
|
||||
pageNumber: number;
|
||||
numPages: number;
|
||||
originalEvent: React.MouseEvent<HTMLDivElement, MouseEvent>;
|
||||
pageHeight: number;
|
||||
pageWidth: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
type PageMeta = {
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
documentDataId: string;
|
||||
};
|
||||
|
||||
type LoadingState = 'loading' | 'loaded' | 'error';
|
||||
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
token: string | undefined;
|
||||
presignToken?: string | undefined;
|
||||
version: DocumentDataVersion;
|
||||
onDocumentLoad?: () => void;
|
||||
overrideImages?: OverrideImage[];
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PDFViewer = ({
|
||||
className,
|
||||
envelopeItem,
|
||||
token,
|
||||
presignToken,
|
||||
version,
|
||||
onDocumentLoad,
|
||||
overrideImages,
|
||||
...props
|
||||
}: PDFViewerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>(
|
||||
overrideImages ? 'loaded' : 'loading',
|
||||
);
|
||||
const [pages, setPages] = useState<PageMeta[]>([]);
|
||||
|
||||
const numPages = overrideImages ? overrideImages.length : pages.length;
|
||||
|
||||
const derivedPages = useMemo((): PageMeta[] => {
|
||||
if (overrideImages) {
|
||||
return overrideImages.map((image) => ({
|
||||
imageUrl: `data:image/jpeg;base64,${image.image.toString('base64')}`,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
documentDataId: '',
|
||||
}));
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [overrideImages, pages]);
|
||||
|
||||
// Fetch metadata when not using override images
|
||||
useEffect(() => {
|
||||
if (overrideImages) {
|
||||
setLoadingState('loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
setLoadingState('loading');
|
||||
|
||||
const metaUrl = getEnvelopeItemMetaUrl({
|
||||
envelopeId: envelopeItem.envelopeId,
|
||||
token,
|
||||
presignToken,
|
||||
});
|
||||
|
||||
const response = await fetch(metaUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch envelope meta: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TGetEnvelopeItemsMetaResponse = await response.json();
|
||||
|
||||
// Find the specific envelope item
|
||||
const itemMeta = data.envelopeItems.find((item) => item.envelopeItemId === envelopeItem.id);
|
||||
|
||||
if (!itemMeta) {
|
||||
throw new Error('Envelope item not found in metadata');
|
||||
}
|
||||
|
||||
// Map pages to our internal format
|
||||
const mappedPages: PageMeta[] = itemMeta.pages.map((page, pageIndex) => {
|
||||
const imageUrl = getEnvelopeItemPageImageUrl({
|
||||
envelopeId: envelopeItem.envelopeId,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
documentDataId: itemMeta.documentDataId,
|
||||
pageIndex,
|
||||
token,
|
||||
presignToken,
|
||||
version,
|
||||
});
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
width: page.originalWidth,
|
||||
height: page.originalHeight,
|
||||
documentDataId: itemMeta.documentDataId,
|
||||
};
|
||||
});
|
||||
|
||||
setPages(mappedPages);
|
||||
setLoadingState('loaded');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoadingState('error');
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while loading the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void fetchMetadata();
|
||||
}, [envelopeItem.envelopeId, envelopeItem.id, token, presignToken, version, overrideImages]);
|
||||
|
||||
// Notify when document is loaded
|
||||
useEffect(() => {
|
||||
if (loadingState === 'loaded' && onDocumentLoad) {
|
||||
onDocumentLoad();
|
||||
}
|
||||
}, [loadingState, onDocumentLoad]);
|
||||
|
||||
const isLoading = loadingState === 'loading';
|
||||
const hasError = loadingState === 'error';
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('h-full w-full overflow-hidden', className)} {...props}>
|
||||
{/* Loading State */}
|
||||
{isLoading && <PdfViewerLoadingState />}
|
||||
|
||||
{/* Error State */}
|
||||
{hasError && <PdfViewerErrorState />}
|
||||
|
||||
{/* Loaded State */}
|
||||
{loadingState === 'loaded' && numPages > 0 && (
|
||||
<VirtualizedPageList
|
||||
scrollParentRef={$el}
|
||||
constraintRef={$el}
|
||||
numPages={numPages}
|
||||
pages={derivedPages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualizedPageListProps = {
|
||||
scrollParentRef: React.RefObject<HTMLDivElement>;
|
||||
constraintRef: React.RefObject<HTMLDivElement>;
|
||||
pages: PageMeta[];
|
||||
numPages: number;
|
||||
};
|
||||
|
||||
// Note: There is a duplicate of this component in `EnvelopePdfViewer`.
|
||||
// This current component is for V1 and legacy use cases.
|
||||
const VirtualizedPageList = ({
|
||||
scrollParentRef,
|
||||
constraintRef,
|
||||
pages,
|
||||
numPages,
|
||||
}: VirtualizedPageListProps) => {
|
||||
const { virtualItems, totalSize, constraintWidth } = useVirtualList({
|
||||
scrollRef: scrollParentRef,
|
||||
constraintRef,
|
||||
itemCount: numPages,
|
||||
itemSize: (index, width) => {
|
||||
const pageMeta = pages[index];
|
||||
|
||||
// Calculate height based on aspect ratio and available width
|
||||
const aspectRatio = pageMeta.height / pageMeta.width;
|
||||
const scaledHeight = width * aspectRatio;
|
||||
|
||||
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
|
||||
return scaledHeight + 32;
|
||||
},
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const index = virtualItem.index;
|
||||
const pageMeta = pages[index];
|
||||
const pageNumber = index + 1;
|
||||
|
||||
// Calculate scale based on constraint width
|
||||
const scale = constraintWidth / pageMeta.width;
|
||||
|
||||
const scaledWidth = pageMeta.width * scale;
|
||||
const scaledHeight = pageMeta.height * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: constraintWidth,
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden rounded border border-border">
|
||||
<div className="relative w-full" style={{ width: scaledWidth, height: scaledHeight }}>
|
||||
<img
|
||||
data-page-number={pageNumber}
|
||||
src={pageMeta.imageUrl}
|
||||
alt=""
|
||||
className={cn(PDF_VIEWER_PAGE_CLASSNAME, 'absolute inset-0 z-0 block')}
|
||||
style={{
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
}}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {pageNumber} of {numPages}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PDFViewer;
|
||||
Reference in New Issue
Block a user