feat: add PDF viewer zoom controls

This commit is contained in:
ephraimduncan
2026-05-21 03:56:09 +00:00
parent c0ea4c60e4
commit 358677fc3a
2 changed files with 195 additions and 12 deletions
@@ -1,8 +1,10 @@
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { MinusIcon, PlusIcon, RotateCcwIcon } from 'lucide-react';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
@@ -27,6 +29,10 @@ type LoadingState = 'loading' | 'loaded' | 'error';
const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
const DEFAULT_ZOOM = 1;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_STEP = 0.25;
export type PDFViewerProps = {
className?: string;
@@ -72,6 +78,22 @@ export default function PDFViewer({
const $el = useRef<HTMLDivElement>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const canZoomOut = zoom > MIN_ZOOM;
const canZoomIn = zoom < MAX_ZOOM;
const zoomOut = () => {
setZoom((currentZoom) => Math.max(MIN_ZOOM, currentZoom - ZOOM_STEP));
};
const resetZoom = () => {
setZoom(DEFAULT_ZOOM);
};
const zoomIn = () => {
setZoom((currentZoom) => Math.min(MAX_ZOOM, currentZoom + ZOOM_STEP));
};
const pdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
@@ -88,6 +110,7 @@ export default function PDFViewer({
try {
setLoadingState('loading');
setPages([]);
setZoom(DEFAULT_ZOOM);
if (isCancelled) {
return;
@@ -109,7 +132,11 @@ export default function PDFViewer({
return;
}
const loadedPdf = await pdfjsLib.getDocument({ data: result!, cMapUrl: '/static/cmaps/' }).promise;
if (!result) {
throw new Error('Failed to load PDF data');
}
const loadedPdf = await pdfjsLib.getDocument({ data: result, cMapUrl: '/static/cmaps/' }).promise;
if (isCancelled) {
await loadedPdf.destroy();
@@ -191,13 +218,57 @@ export default function PDFViewer({
}
return (
<div ref={$el} className={cn('h-full w-full', className)} {...props}>
<div ref={$el} className={cn('h-full w-full overflow-x-auto', className)} {...props}>
{/* Loading State */}
{isLoading && <PdfViewerLoadingState />}
{/* Error State */}
{hasError && <PdfViewerErrorState />}
{loadingState === 'loaded' && (
<div className="sticky top-2 right-2 z-20 ml-auto flex w-fit items-center gap-1 rounded-md border bg-background/95 p-1 shadow-sm">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0"
disabled={!canZoomOut}
aria-label={t`Zoom out`}
onClick={zoomOut}
>
<MinusIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Zoom out</Trans>
</span>
</Button>
<Button
type="button"
variant="ghost"
className="h-8 min-w-12 px-2 font-medium text-xs tabular-nums"
disabled={zoom === DEFAULT_ZOOM}
aria-label={t`Reset zoom`}
onClick={resetZoom}
>
<RotateCcwIcon className="mr-1 h-3.5 w-3.5" />
{Math.round(zoom * 100)}%
</Button>
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0"
disabled={!canZoomIn}
aria-label={t`Zoom in`}
onClick={zoomIn}
>
<PlusIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Zoom in</Trans>
</span>
</Button>
</div>
)}
{/* Loaded State */}
{loadingState === 'loaded' && pages.length > 0 && pdfRef.current && (
<VirtualizedPageList
@@ -206,6 +277,7 @@ export default function PDFViewer({
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
zoom={zoom}
customPageRenderer={customPageRenderer}
/>
)}
@@ -220,6 +292,7 @@ type VirtualizedPageListProps = {
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
zoom: number;
};
const VirtualizedPageList = ({
@@ -229,6 +302,7 @@ const VirtualizedPageList = ({
numPages,
pdf,
customPageRenderer,
zoom,
}: VirtualizedPageListProps) => {
const contentRef = useRef<HTMLDivElement>(null);
@@ -240,9 +314,9 @@ const VirtualizedPageList = ({
itemSize: (index, width) => {
const pageMeta = pages[index];
// Calculate height based on aspect ratio and available width
// Calculate height based on aspect ratio and zoomed width
const aspectRatio = pageMeta.height / pageMeta.width;
const scaledHeight = width * aspectRatio;
const scaledHeight = width * zoom * aspectRatio;
// Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
// Add additional 2px for the top and bottom borders.
@@ -261,7 +335,7 @@ const VirtualizedPageList = ({
data-page-count={numPages}
style={{
height: `${totalSize}px`,
width: '100%',
width: `${Math.max(constraintWidth, constraintWidth * zoom)}px`,
position: 'relative',
}}
>
@@ -270,20 +344,22 @@ const VirtualizedPageList = ({
const pageMeta = pages[index];
const pageNumber = index + 1;
// Calculate scale based on constraint width
const scale = constraintWidth / pageMeta.width;
// Calculate scale based on fit-to-width plus viewer zoom
const pageDisplayWidth = constraintWidth * zoom;
const scale = pageDisplayWidth / pageMeta.width;
const scaledWidth = Math.floor(pageMeta.width * scale);
const scaledHeight = Math.floor(pageMeta.height * scale);
return (
<div
className="flex flex-col items-center"
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: constraintWidth,
width: Math.max(constraintWidth, scaledWidth),
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
@@ -373,6 +449,7 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const renderedResolutionRef = useRef<number | null>(null);
const renderedScaleRef = useRef<number | null>(null);
const renderedPageNumberRef = useRef<number | null>(null);
const renderedPdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
@@ -392,7 +469,8 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
return (
renderedPdfRef.current === pdf &&
renderedPageNumberRef.current === pageNumber &&
renderedResolutionRef.current === resolution
renderedResolutionRef.current === resolution &&
renderedScaleRef.current === scale
);
};
@@ -400,6 +478,7 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
renderedPdfRef.current = pdf;
renderedPageNumberRef.current = pageNumber;
renderedResolutionRef.current = resolution;
renderedScaleRef.current = scale;
};
const renderAtResolution = async (resolution: number) => {
@@ -13,9 +13,9 @@ import {
} from '@documenso/prisma/seed/documents';
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { expect, type Locator, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
import { apiSignin } from '../fixtures/authentication';
export const PDF_PAGE_SELECTOR = 'img[data-page-number]';
@@ -46,6 +46,16 @@ async function addSecondEnvelopeItem(envelopeId: string) {
});
}
async function getLocatorWidth(locator: Locator) {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Locator bounding box not found');
}
return box.width;
}
test.describe('PDF Viewer Rendering', () => {
test.describe('Authenticated Pages', () => {
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
@@ -101,6 +111,38 @@ test.describe('PDF Viewer Rendering', () => {
await page.goto(`/t/${team.url}/documents/${documentV1.id}`);
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
});
test('should zoom in and reset to fit width', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}`,
});
const pageImage = page.locator(PDF_PAGE_SELECTOR).first();
const pdfContent = page.locator('[data-pdf-content]');
await expect(pageImage).toBeVisible({ timeout: 30_000 });
const initialImageWidth = await getLocatorWidth(pageImage);
const initialContentWidth = await getLocatorWidth(pdfContent);
expect(Math.abs(initialImageWidth - initialContentWidth)).toBeLessThanOrEqual(2);
await page.getByRole('button', { name: 'Zoom in' }).click();
await expect.poll(async () => await getLocatorWidth(pageImage)).toBeGreaterThan(initialImageWidth);
await expect.poll(async () => await getLocatorWidth(pdfContent)).toBeGreaterThan(initialContentWidth);
await page.getByRole('button', { name: 'Reset zoom' }).click();
await expect
.poll(async () => Math.abs((await getLocatorWidth(pageImage)) - initialImageWidth))
.toBeLessThanOrEqual(2);
});
});
test.describe('Recipient Signing', () => {
@@ -131,6 +173,68 @@ test.describe('PDF Viewer Rendering', () => {
await page.getByRole('button', { name: /Page 2/ }).click();
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
});
test('should keep V2 signing fields clickable after zooming', async ({ page, request }) => {
const { distributeResult, envelope } = await apiSeedPendingDocument(request, {
recipients: [{ email: 'pdf-zoom-signer@test.documenso.com', name: 'PDF Zoom Signer' }],
fieldsPerRecipient: [
[
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 10,
positionY: 10,
width: 15,
height: 5,
},
],
],
});
const { token } = distributeResult.recipients[0];
await page.goto(`/sign/${token}`);
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('1 Field Remaining').first()).toBeVisible();
const initialCanvasWidth = await getLocatorWidth(canvas);
await page.getByRole('button', { name: 'Zoom in' }).click();
await expect.poll(async () => await getLocatorWidth(canvas)).toBeGreaterThan(initialCanvasWidth);
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
await page.getByRole('button', { name: 'Next' }).click();
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
if (!signatureField) {
throw new Error('Signature field not found');
}
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(signatureField.positionX) / 100) * canvasBox.width +
((Number(signatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(signatureField.positionY) / 100) * canvasBox.height +
((Number(signatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
});
});
test.describe('Direct Template', () => {
@@ -168,7 +272,7 @@ test.describe('PDF Viewer Rendering', () => {
const qrTokenV1 = prefixedId('qr');
const qrTokenV2 = prefixedId('qr');
const documentV1 = await seedCompletedDocument(user, team.id, ['share-v1@test.documenso.com'], {
await seedCompletedDocument(user, team.id, ['share-v1@test.documenso.com'], {
createDocumentOptions: { qrToken: qrTokenV1 },
});