From 358677fc3aff09aafc32f653db705e7c281aabbf Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 21 May 2026 03:56:09 +0000 Subject: [PATCH] feat: add PDF viewer zoom controls --- .../general/pdf-viewer/pdf-viewer.tsx | 97 +++++++++++++-- .../e2e/pdf-viewer/pdf-viewer.spec.ts | 110 +++++++++++++++++- 2 files changed, 195 insertions(+), 12 deletions(-) diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx index 56c5308d6..74ed50c3f 100644 --- a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx @@ -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(null); const [loadingState, setLoadingState] = useState('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(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 ( -
+
{/* Loading State */} {isLoading && } {/* Error State */} {hasError && } + {loadingState === 'loaded' && ( +
+ + + + + +
+ )} + {/* Loaded State */} {loadingState === 'loaded' && pages.length > 0 && pdfRef.current && ( )} @@ -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(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 (
| null>(null); const renderedResolutionRef = useRef(null); + const renderedScaleRef = useRef(null); const renderedPageNumberRef = useRef(null); const renderedPdfRef = useRef(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) => { diff --git a/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts index a2650b5e2..9ae7efbf9 100644 --- a/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts +++ b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts @@ -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 }, });