Files
sign/packages/ui/primitives/pdf-viewer.tsx

287 lines
8.6 KiB
TypeScript
Raw Normal View History

2023-06-09 18:21:18 +10:00
'use client';
2023-10-21 13:08:29 +11:00
import React, { useEffect, useMemo, useRef, useState } from 'react';
2023-06-09 18:21:18 +10:00
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist';
2023-06-09 18:21:18 +10:00
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
2023-09-20 13:48:30 +10:00
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
2023-10-20 13:42:10 +11:00
import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { DocumentData, DocumentMeta } from '@documenso/prisma/client';
2023-06-09 18:21:18 +10:00
import { cn } from '../lib/utils';
import { PasswordDialog } from './document-password-dialog';
import { useToast } from './use-toast';
import { trpc } from '@documenso/trpc/react';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
2023-10-20 13:42:10 +11:00
export type LoadedPDFDocument = PDFDocumentProxy;
2023-06-09 18:21:18 +10:00
2023-06-10 22:33:12 +10:00
/**
* This imports the worker from the `pdfjs-dist` package.
*/
pdfjs.GlobalWorkerOptions.workerSrc = `/pdf.worker.min.js`;
2023-06-09 18:21:18 +10:00
2023-06-10 22:33:12 +10:00
export type OnPDFViewerPageClick = (_event: {
pageNumber: number;
numPages: number;
originalEvent: React.MouseEvent<HTMLDivElement, MouseEvent>;
pageHeight: number;
pageWidth: number;
pageX: number;
pageY: number;
}) => void | Promise<void>;
2023-10-20 13:42:10 +11:00
const PDFLoader = () => (
<>
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
</>
);
2023-06-09 18:21:18 +10:00
export type PDFViewerProps = {
className?: string;
2023-10-20 13:42:10 +11:00
documentData: DocumentData;
document?: DocumentWithData;
documentMeta?: DocumentMeta | null;
2023-09-20 13:48:30 +10:00
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
2023-06-10 22:33:12 +10:00
onPageClick?: OnPDFViewerPageClick;
2023-06-09 18:21:18 +10:00
[key: string]: unknown;
2023-09-20 13:48:30 +10:00
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PDFViewer = ({
className,
2023-10-20 13:42:10 +11:00
documentData,
document,
documentMeta,
2023-09-20 13:48:30 +10:00
onDocumentLoad,
onPageClick,
...props
}: PDFViewerProps) => {
2023-10-20 13:42:10 +11:00
const { toast } = useToast();
2023-06-09 18:21:18 +10:00
const $el = useRef<HTMLDivElement>(null);
2023-10-20 13:42:10 +11:00
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [password, setPassword] = useState<string | null>(documentMeta?.documentPassword || null);
const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null);
const [isPasswordError, setIsPasswordError] = useState(false);
2023-10-20 13:42:10 +11:00
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null);
2023-06-09 18:21:18 +10:00
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
2023-09-20 13:48:30 +10:00
const [pdfError, setPdfError] = useState(false);
2023-06-09 18:21:18 +10:00
2023-10-21 13:08:29 +11:00
const memoizedData = useMemo(
() => ({ type: documentData.type, data: documentData.data }),
[documentData.data, documentData.type],
);
const {mutateAsync: addDocumentPassword } = trpc.document.setDocumentPassword.useMutation();
2023-10-20 20:14:10 +11:00
const isLoading = isDocumentBytesLoading || !documentBytes;
2023-06-09 18:21:18 +10:00
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
setNumPages(doc.numPages);
2023-09-20 13:48:30 +10:00
onDocumentLoad?.(doc);
2023-06-09 18:21:18 +10:00
};
const onPasswordSubmit = async() => {
setIsPasswordModalOpen(false);
try{
await addDocumentPassword({
documentId: document?.id ?? 0,
documentPassword: password!,
});
passwordCallbackRef.current?.(password);
} catch (error) {
console.error('Error adding document password:', error);
} finally {
passwordCallbackRef.current = null;
}
};
2023-06-09 18:21:18 +10:00
2023-06-10 22:33:12 +10:00
const onDocumentPageClick = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
pageNumber: number,
) => {
const $el = event.target instanceof HTMLElement ? event.target : null;
if (!$el) {
return;
}
2023-09-20 13:48:30 +10:00
const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR);
2023-06-10 22:33:12 +10:00
if (!$page) {
return;
}
const { height, width, top, left } = $page.getBoundingClientRect();
const pageX = event.clientX - left;
const pageY = event.clientY - top;
if (onPageClick) {
2023-08-29 13:01:19 +10:00
void onPageClick({
2023-06-10 22:33:12 +10:00
pageNumber,
numPages,
originalEvent: event,
pageHeight: height,
pageWidth: width,
pageX,
pageY,
});
}
};
2023-06-09 18:21:18 +10:00
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);
};
}
}, []);
2023-10-20 13:42:10 +11:00
useEffect(() => {
const fetchDocumentBytes = async () => {
try {
setIsDocumentBytesLoading(true);
2023-10-21 13:08:29 +11:00
const bytes = await getFile(memoizedData);
2023-10-20 13:42:10 +11:00
setDocumentBytes(bytes);
setIsDocumentBytesLoading(false);
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while loading the document.',
variant: 'destructive',
});
}
};
void fetchDocumentBytes();
2023-10-21 13:08:29 +11:00
}, [memoizedData, toast]);
2023-10-20 13:42:10 +11:00
2023-06-09 18:21:18 +10:00
return (
2023-06-10 22:33:12 +10:00
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
2023-10-20 20:14:10 +11:00
{isLoading ? (
<div
className={cn(
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
)}
>
2023-10-20 13:42:10 +11:00
<PDFLoader />
</div>
) : (
<>
<PDFDocument
file={documentBytes.buffer}
className={cn('w-full overflow-hidden rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onPassword={(callback, reason) => {
// If the documentMeta already has a password, we don't need to ask for it again.
if(password && reason !== PasswordResponses.INCORRECT_PASSWORD){
callback(password);
return;
}
setIsPasswordModalOpen(true);
passwordCallbackRef.current = callback;
switch (reason) {
case PasswordResponses.NEED_PASSWORD:
setIsPasswordError(false);
break;
case PasswordResponses.INCORRECT_PASSWORD:
setIsPasswordError(true);
break;
default:
break;
}
}}
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="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
{pdfError ? (
<div className="text-muted-foreground text-center">
<p>Something went wrong while loading the document.</p>
<p className="mt-1 text-sm">Please try again or contact our support.</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
2023-10-20 13:42:10 +11:00
<div className="text-muted-foreground text-center">
<p>Something went wrong while loading the document.</p>
<p className="mt-1 text-sm">Please try again or contact our support.</p>
</div>
2023-09-20 13:48:30 +10:00
</div>
}
>
{Array(numPages)
.fill(null)
.map((_, i) => (
<div
key={i}
className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0"
>
<PDFPage
pageNumber={i + 1}
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
loading={() => ''}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
</div>
))}
</PDFDocument>
<PasswordDialog
open={isPasswordModalOpen}
onOpenChange={setIsPasswordModalOpen}
onPasswordSubmit={onPasswordSubmit}
isError={isPasswordError}
setPassword={setPassword}
/>
</>
)}
2023-06-09 18:21:18 +10:00
</div>
);
};
export default PDFViewer;