mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: add PDF viewer zoom controls
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
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 { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { MinusIcon, PlusIcon, RotateCcwIcon } from 'lucide-react';
|
||||||
import pMap from 'p-map';
|
import pMap from 'p-map';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
|
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
|
||||||
@@ -27,6 +29,10 @@ type LoadingState = 'loading' | 'loaded' | 'error';
|
|||||||
const LOW_RENDER_RESOLUTION = 1;
|
const LOW_RENDER_RESOLUTION = 1;
|
||||||
const HIGH_RENDER_RESOLUTION = 2;
|
const HIGH_RENDER_RESOLUTION = 2;
|
||||||
const IDLE_RENDER_DELAY = 200;
|
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 = {
|
export type PDFViewerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -72,6 +78,22 @@ export default function PDFViewer({
|
|||||||
const $el = useRef<HTMLDivElement>(null);
|
const $el = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
|
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);
|
const pdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||||
|
|
||||||
@@ -88,6 +110,7 @@ export default function PDFViewer({
|
|||||||
try {
|
try {
|
||||||
setLoadingState('loading');
|
setLoadingState('loading');
|
||||||
setPages([]);
|
setPages([]);
|
||||||
|
setZoom(DEFAULT_ZOOM);
|
||||||
|
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -109,7 +132,11 @@ export default function PDFViewer({
|
|||||||
return;
|
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) {
|
if (isCancelled) {
|
||||||
await loadedPdf.destroy();
|
await loadedPdf.destroy();
|
||||||
@@ -191,13 +218,57 @@ export default function PDFViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Loading State */}
|
||||||
{isLoading && <PdfViewerLoadingState />}
|
{isLoading && <PdfViewerLoadingState />}
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
{hasError && <PdfViewerErrorState />}
|
{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 */}
|
{/* Loaded State */}
|
||||||
{loadingState === 'loaded' && pages.length > 0 && pdfRef.current && (
|
{loadingState === 'loaded' && pages.length > 0 && pdfRef.current && (
|
||||||
<VirtualizedPageList
|
<VirtualizedPageList
|
||||||
@@ -206,6 +277,7 @@ export default function PDFViewer({
|
|||||||
numPages={pages.length}
|
numPages={pages.length}
|
||||||
pages={pages}
|
pages={pages}
|
||||||
pdf={pdfRef.current}
|
pdf={pdfRef.current}
|
||||||
|
zoom={zoom}
|
||||||
customPageRenderer={customPageRenderer}
|
customPageRenderer={customPageRenderer}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -220,6 +292,7 @@ type VirtualizedPageListProps = {
|
|||||||
numPages: number;
|
numPages: number;
|
||||||
pdf: pdfjsLib.PDFDocumentProxy;
|
pdf: pdfjsLib.PDFDocumentProxy;
|
||||||
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
|
||||||
|
zoom: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VirtualizedPageList = ({
|
const VirtualizedPageList = ({
|
||||||
@@ -229,6 +302,7 @@ const VirtualizedPageList = ({
|
|||||||
numPages,
|
numPages,
|
||||||
pdf,
|
pdf,
|
||||||
customPageRenderer,
|
customPageRenderer,
|
||||||
|
zoom,
|
||||||
}: VirtualizedPageListProps) => {
|
}: VirtualizedPageListProps) => {
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -240,9 +314,9 @@ const VirtualizedPageList = ({
|
|||||||
itemSize: (index, width) => {
|
itemSize: (index, width) => {
|
||||||
const pageMeta = pages[index];
|
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 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 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
|
||||||
// Add additional 2px for the top and bottom borders.
|
// Add additional 2px for the top and bottom borders.
|
||||||
@@ -261,7 +335,7 @@ const VirtualizedPageList = ({
|
|||||||
data-page-count={numPages}
|
data-page-count={numPages}
|
||||||
style={{
|
style={{
|
||||||
height: `${totalSize}px`,
|
height: `${totalSize}px`,
|
||||||
width: '100%',
|
width: `${Math.max(constraintWidth, constraintWidth * zoom)}px`,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -270,20 +344,22 @@ const VirtualizedPageList = ({
|
|||||||
const pageMeta = pages[index];
|
const pageMeta = pages[index];
|
||||||
const pageNumber = index + 1;
|
const pageNumber = index + 1;
|
||||||
|
|
||||||
// Calculate scale based on constraint width
|
// Calculate scale based on fit-to-width plus viewer zoom
|
||||||
const scale = constraintWidth / pageMeta.width;
|
const pageDisplayWidth = constraintWidth * zoom;
|
||||||
|
const scale = pageDisplayWidth / pageMeta.width;
|
||||||
|
|
||||||
const scaledWidth = Math.floor(pageMeta.width * scale);
|
const scaledWidth = Math.floor(pageMeta.width * scale);
|
||||||
const scaledHeight = Math.floor(pageMeta.height * scale);
|
const scaledHeight = Math.floor(pageMeta.height * scale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="flex flex-col items-center"
|
||||||
key={virtualItem.key}
|
key={virtualItem.key}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: constraintWidth,
|
width: Math.max(constraintWidth, scaledWidth),
|
||||||
height: `${virtualItem.size}px`,
|
height: `${virtualItem.size}px`,
|
||||||
transform: `translateY(${virtualItem.start}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 idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const renderedResolutionRef = useRef<number | null>(null);
|
const renderedResolutionRef = useRef<number | null>(null);
|
||||||
|
const renderedScaleRef = useRef<number | null>(null);
|
||||||
const renderedPageNumberRef = useRef<number | null>(null);
|
const renderedPageNumberRef = useRef<number | null>(null);
|
||||||
const renderedPdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
|
const renderedPdfRef = useRef<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||||
|
|
||||||
@@ -392,7 +469,8 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
|
|||||||
return (
|
return (
|
||||||
renderedPdfRef.current === pdf &&
|
renderedPdfRef.current === pdf &&
|
||||||
renderedPageNumberRef.current === pageNumber &&
|
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;
|
renderedPdfRef.current = pdf;
|
||||||
renderedPageNumberRef.current = pageNumber;
|
renderedPageNumberRef.current = pageNumber;
|
||||||
renderedResolutionRef.current = resolution;
|
renderedResolutionRef.current = resolution;
|
||||||
|
renderedScaleRef.current = scale;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAtResolution = async (resolution: number) => {
|
const renderAtResolution = async (resolution: number) => {
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
} from '@documenso/prisma/seed/documents';
|
} from '@documenso/prisma/seed/documents';
|
||||||
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
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 { FieldType } from '@prisma/client';
|
||||||
|
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
export const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
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('PDF Viewer Rendering', () => {
|
||||||
test.describe('Authenticated Pages', () => {
|
test.describe('Authenticated Pages', () => {
|
||||||
test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => {
|
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.goto(`/t/${team.url}/documents/${documentV1.id}`);
|
||||||
await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 });
|
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', () => {
|
test.describe('Recipient Signing', () => {
|
||||||
@@ -131,6 +173,68 @@ test.describe('PDF Viewer Rendering', () => {
|
|||||||
await page.getByRole('button', { name: /Page 2/ }).click();
|
await page.getByRole('button', { name: /Page 2/ }).click();
|
||||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
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', () => {
|
test.describe('Direct Template', () => {
|
||||||
@@ -168,7 +272,7 @@ test.describe('PDF Viewer Rendering', () => {
|
|||||||
const qrTokenV1 = prefixedId('qr');
|
const qrTokenV1 = prefixedId('qr');
|
||||||
const qrTokenV2 = 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 },
|
createDocumentOptions: { qrToken: qrTokenV1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user