mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 06:47:10 +10:00
feat: add pdf image renderer
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
@@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
@@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
||||
|
||||
// Step 3: Add fields
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
// Switch to second duplicate and add field
|
||||
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Continue to send
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
||||
// Step 3: Add fields for each recipient
|
||||
// Add signature field for first duplicate recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to second duplicate recipient and add their field
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
|
||||
|
||||
// Switch to unique recipient and add their field
|
||||
await page.getByText('Unique Recipient (unique@example.com)').click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
|
||||
// Continue to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Save the document by going to subject
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Complete the flow
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add signature for first recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Add name field for second recipient
|
||||
await page.getByRole('combobox').first().click();
|
||||
|
||||
await page.getByText('Approver Role (signer@example.com)').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Add date field for second recipient
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
|
||||
|
||||
// If second recipient is still a SIGNER (role change wasn't available),
|
||||
// add a signature field for them to pass validation
|
||||
if (!secondRecipientIsApprover) {
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
|
||||
}
|
||||
|
||||
// Complete the document
|
||||
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add another field to the second duplicate
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
@@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByText('User 2 (user2@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
@@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
@@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
@@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
@@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100 * i,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||
@@ -378,39 +377,14 @@ test.skip('download envelope images', async ({ page }) => {
|
||||
});
|
||||
|
||||
async function renderPdfToImage(pdfBytes: Uint8Array) {
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
// Increase for higher resolution
|
||||
const scale = 4;
|
||||
|
||||
return await Promise.all(
|
||||
Array.from({ length: pdf.numPages }, async (_, index) => {
|
||||
const page = await pdf.getPage(index + 1);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
canvasContext.imageSmoothingEnabled = false;
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvas,
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
image: await canvas.encode('png'),
|
||||
|
||||
// Rounded down because the certificate page somehow gives dimensions with decimals
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return (await pdfToImages(pdfBytes, { scale, imageFormat: 'png' })).map((image) => ({
|
||||
image: image.image,
|
||||
width: Math.floor(image.scaledWidth),
|
||||
height: Math.floor(image.scaledHeight),
|
||||
}));
|
||||
}
|
||||
|
||||
type CompareSignedPdfWithImagesOptions = {
|
||||
|
||||
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
||||
// Step 3: Add fields for each recipient instance
|
||||
// Add signature field for first instance
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
// Switch to second instance and add their field
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Second Instance').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
// Switch to different recipient and add their fields
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Add fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Duplicate Recipient 2').first().click();
|
||||
await page.getByRole('button', { name: 'Date' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
|
||||
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByText('Different Recipient').first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
|
||||
|
||||
// Save template
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
@@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'First Instance' }).first().click();
|
||||
await page.getByRole('button', { name: 'Name' }).click();
|
||||
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
@@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
@@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 500,
|
||||
@@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
@@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Text' }).click();
|
||||
await page.locator('canvas').click({
|
||||
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const getBoundingClientRect = (element: HTMLElement) => {
|
||||
export const getBoundingClientRect = (element: HTMLElement | Element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
const { width, height } = rect;
|
||||
|
||||
@@ -14,7 +14,10 @@ export const useDocumentElement = () => {
|
||||
const target = event.target;
|
||||
|
||||
const $page =
|
||||
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector);
|
||||
target.closest<HTMLElement>(pageSelector) ??
|
||||
document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find((el) => el.matches(pageSelector));
|
||||
|
||||
if (!$page) {
|
||||
return null;
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import Konva from 'konva';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
|
||||
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
|
||||
|
||||
import { EAGER_LOAD_PAGE_COUNT, type PageRenderData } from '../providers/envelope-render-provider';
|
||||
|
||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
||||
|
||||
export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
const pageContext = usePageContext();
|
||||
export function usePageRenderer(renderFunction: RenderFunction, pageData: PageRenderData) {
|
||||
const { pageWidth, pageHeight, scale, imageUrl, pageNumber } = pageData;
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
||||
|
||||
const [renderError, setRenderError] = useState<boolean>(false);
|
||||
const [renderStatus, setRenderStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
|
||||
|
||||
/**
|
||||
* The raw viewport with no scaling. Basically the actual PDF size.
|
||||
*/
|
||||
const unscaledViewport = useMemo(
|
||||
() => page.getViewport({ scale: 1, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
() => ({
|
||||
scale: 1,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
}),
|
||||
[pageWidth, pageHeight],
|
||||
);
|
||||
|
||||
/**
|
||||
* The viewport scaled according to page width.
|
||||
*/
|
||||
const scaledViewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
() => ({
|
||||
scale,
|
||||
width: pageWidth * scale,
|
||||
height: pageHeight * scale,
|
||||
}),
|
||||
[pageWidth, pageHeight, scale],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -48,88 +47,77 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
* in a higher resolution.
|
||||
*/
|
||||
const renderViewport = useMemo(
|
||||
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
() => ({
|
||||
scale: scale * window.devicePixelRatio,
|
||||
width: pageWidth * scale * window.devicePixelRatio,
|
||||
height: pageHeight * scale * window.devicePixelRatio,
|
||||
}),
|
||||
[pageWidth, pageHeight, scale],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the PDF and create the scaled Konva stage.
|
||||
* The props for the image element which will render the page.
|
||||
*/
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: kContainer } = konvaContainer;
|
||||
|
||||
if (!canvas || !kContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = renderViewport.width;
|
||||
canvas.height = renderViewport.height;
|
||||
|
||||
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
canvas,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport: renderViewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
stage.current = new Konva.Stage({
|
||||
container: kContainer,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
scale: {
|
||||
x: scale,
|
||||
y: scale,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
|
||||
stage.current.add(pageLayer.current);
|
||||
|
||||
renderFunction({
|
||||
stage: stage.current,
|
||||
pageLayer: pageLayer.current,
|
||||
});
|
||||
|
||||
void document.fonts.ready.then(function () {
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, scaledViewport],
|
||||
const imageProps = useMemo(
|
||||
(): React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' } => ({
|
||||
className: PDF_VIEWER_PAGE_CLASSNAME,
|
||||
width: `${Math.floor(scaledViewport.width)}px`,
|
||||
height: `${Math.floor(scaledViewport.height)}px`,
|
||||
alt: '',
|
||||
onLoad: () => setRenderStatus('loaded'),
|
||||
// Purposely not using lazy here since we can use the virtual list overscan to let us prerender images.
|
||||
loading: pageNumber < EAGER_LOAD_PAGE_COUNT ? 'eager' : undefined,
|
||||
src: imageUrl,
|
||||
'data-page-number': pageNumber,
|
||||
}),
|
||||
[renderViewport, scaledViewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (renderStatus !== 'loaded' || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
scale: {
|
||||
x: scale,
|
||||
y: scale,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
|
||||
stage.current.add(pageLayer.current);
|
||||
|
||||
renderFunction({
|
||||
stage: stage.current,
|
||||
pageLayer: pageLayer.current,
|
||||
});
|
||||
|
||||
void document.fonts.ready.then(function () {
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
|
||||
return () => {
|
||||
stage.current?.destroy();
|
||||
stage.current = null;
|
||||
};
|
||||
}, [renderStatus, imageProps]);
|
||||
|
||||
return {
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
imageProps,
|
||||
stage,
|
||||
pageLayer,
|
||||
unscaledViewport,
|
||||
scaledViewport,
|
||||
pageContext,
|
||||
renderError,
|
||||
setRenderError,
|
||||
renderStatus,
|
||||
setRenderStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
|
||||
import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import type { DocumentDataVersion } from '../../types/document-data';
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
||||
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
|
||||
import { getEnvelopeItemMetaUrl, getEnvelopeItemPageImageUrl } from '../../utils/envelope-images';
|
||||
|
||||
type FileData =
|
||||
| {
|
||||
status: 'loading' | 'error';
|
||||
}
|
||||
| {
|
||||
file: Uint8Array;
|
||||
status: 'loaded';
|
||||
};
|
||||
/**
|
||||
* Number of pages to load eagerly on initial render.
|
||||
* Pages beyond this threshold will be loaded lazily when they enter the viewport.
|
||||
*/
|
||||
export const EAGER_LOAD_PAGE_COUNT = 5;
|
||||
|
||||
export type PageRenderData = BasePageRenderData & {
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export type BasePageRenderData = {
|
||||
envelopeItemId: string;
|
||||
documentDataId: string;
|
||||
pageIndex: number;
|
||||
pageNumber: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
|
||||
|
||||
type EnvelopeRenderOverrideSettings = {
|
||||
mode?: FieldRenderMode;
|
||||
@@ -28,8 +51,10 @@ type EnvelopeRenderOverrideSettings = {
|
||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
||||
|
||||
type EnvelopeRenderProviderValue = {
|
||||
getPdfBuffer: (envelopeItemId: string) => FileData | null;
|
||||
version: DocumentDataVersion;
|
||||
envelopeItems: EnvelopeRenderItem[];
|
||||
envelopeItemsMeta: Record<string, BasePageRenderData[]>;
|
||||
envelopeItemsMetaLoadingState: ImageLoadingState;
|
||||
envelopeStatus: TEnvelope['status'];
|
||||
envelopeType: TEnvelope['type'];
|
||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||
@@ -46,7 +71,12 @@ type EnvelopeRenderProviderValue = {
|
||||
interface EnvelopeRenderProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
|
||||
/**
|
||||
* The envelope item version to render.
|
||||
*/
|
||||
version: DocumentDataVersion;
|
||||
|
||||
envelope: Pick<TEnvelope, 'id' | 'envelopeItems' | 'status' | 'type'>;
|
||||
|
||||
/**
|
||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||
@@ -89,7 +119,7 @@ export const useCurrentEnvelopeRender = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages fetching and storing PDF files to render on the client.
|
||||
* Manages fetching the data required to render an envelope and it's items.
|
||||
*/
|
||||
export const EnvelopeRenderProvider = ({
|
||||
children,
|
||||
@@ -97,71 +127,107 @@ export const EnvelopeRenderProvider = ({
|
||||
fields,
|
||||
token,
|
||||
recipients = [],
|
||||
version,
|
||||
overrideSettings,
|
||||
}: EnvelopeRenderProviderProps) => {
|
||||
// Indexed by documentDataId.
|
||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
||||
// Indexed by envelope item ID.
|
||||
const [envelopeItemsMeta, setEnvelopeItemsMeta] = useState<Record<string, BasePageRenderData[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
|
||||
const [envelopeItemsMetaLoadingState, setEnvelopeItemsMetaLoadingState] =
|
||||
useState<ImageLoadingState>('loading');
|
||||
|
||||
const [renderError, setRenderError] = useState<boolean>(false);
|
||||
|
||||
// Track the timestamp of the most recent fetch to prevent race conditions
|
||||
const fetchStartedAtRef = useRef<number>(0);
|
||||
|
||||
const envelopeItems = useMemo(
|
||||
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
|
||||
[envelope.envelopeItems],
|
||||
);
|
||||
|
||||
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
|
||||
if (files[envelopeItem.id]?.status === 'loading') {
|
||||
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
|
||||
envelope.envelopeItems[0] ?? null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch metadata and preload initial images when the envelope or token changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
void fetchEnvelopeRenderData();
|
||||
}, [envelope.id, envelope.envelopeItems.length, token]);
|
||||
|
||||
const fetchEnvelopeRenderData = async () => {
|
||||
if (!envelope.id || envelope.envelopeItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files[envelopeItem.id]) {
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[envelopeItem.id]: {
|
||||
status: 'loading',
|
||||
},
|
||||
}));
|
||||
}
|
||||
// Record when this fetch started to detect stale responses
|
||||
const fetchStartedAt = Date.now();
|
||||
fetchStartedAtRef.current = fetchStartedAt;
|
||||
|
||||
setEnvelopeItemsMetaLoadingState('loading');
|
||||
|
||||
try {
|
||||
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'view',
|
||||
envelopeItem: envelopeItem,
|
||||
// Fetch metadata for all envelope items
|
||||
const metaUrl = getEnvelopeItemMetaUrl({
|
||||
envelopeId: envelope.id,
|
||||
token,
|
||||
});
|
||||
|
||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||
const response = await fetch(metaUrl);
|
||||
|
||||
const file = await blob.arrayBuffer();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch envelope meta: ${response.status}`);
|
||||
}
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[envelopeItem.id]: {
|
||||
file: new Uint8Array(file),
|
||||
status: 'loaded',
|
||||
},
|
||||
}));
|
||||
const data: TGetEnvelopeItemsMetaResponse = await response.json();
|
||||
|
||||
// Check again after parsing JSON in case a newer fetch started
|
||||
if (fetchStartedAtRef.current !== fetchStartedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a map of envelope items by ID
|
||||
const metaMap: Record<string, BasePageRenderData[]> = {};
|
||||
|
||||
for (const item of data.envelopeItems) {
|
||||
metaMap[item.envelopeItemId] = item.pages.map((page, pageIndex) => {
|
||||
const imageUrl = getEnvelopeItemPageImageUrl({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: item.envelopeItemId,
|
||||
documentDataId: item.documentDataId,
|
||||
pageIndex,
|
||||
token,
|
||||
version,
|
||||
});
|
||||
|
||||
return {
|
||||
envelopeItemId: item.envelopeItemId,
|
||||
documentDataId: item.documentDataId,
|
||||
pageIndex,
|
||||
pageNumber: pageIndex + 1,
|
||||
pageWidth: page.originalWidth,
|
||||
pageHeight: page.originalHeight,
|
||||
imageUrl,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setEnvelopeItemsMeta(metaMap);
|
||||
|
||||
setEnvelopeItemsMetaLoadingState('loaded');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[envelopeItem.id]: {
|
||||
status: 'error',
|
||||
},
|
||||
}));
|
||||
// Only set error state if this is still the most recent fetch
|
||||
if (fetchStartedAtRef.current === fetchStartedAt) {
|
||||
console.error('Failed to load envelope data:', error);
|
||||
setEnvelopeItemsMetaLoadingState('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getPdfBuffer = useCallback(
|
||||
(envelopeItemId: string) => {
|
||||
return files[envelopeItemId] || null;
|
||||
},
|
||||
[files],
|
||||
);
|
||||
|
||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
|
||||
@@ -179,15 +245,6 @@ export const EnvelopeRenderProvider = ({
|
||||
}
|
||||
}, [currentItem, envelopeItems]);
|
||||
|
||||
// Look for any missing pdf files and load them.
|
||||
useEffect(() => {
|
||||
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
|
||||
|
||||
for (const item of missingFiles) {
|
||||
void loadEnvelopeItemPdfFile(item);
|
||||
}
|
||||
}, [envelope.envelopeItems]);
|
||||
|
||||
const recipientIds = useMemo(
|
||||
() => recipients.map((recipient) => recipient.id).sort(),
|
||||
[recipients],
|
||||
@@ -207,7 +264,9 @@ export const EnvelopeRenderProvider = ({
|
||||
return (
|
||||
<EnvelopeRenderContext.Provider
|
||||
value={{
|
||||
getPdfBuffer,
|
||||
version,
|
||||
envelopeItemsMeta,
|
||||
envelopeItemsMetaLoadingState,
|
||||
envelopeItems,
|
||||
envelopeStatus: envelope.status,
|
||||
envelopeType: envelope.type,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
// This is separate from the pdf-viewer.ts constant file due to parsing issues during testing.
|
||||
export const PDF_VIEWER_ERROR_MESSAGES = {
|
||||
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.`,
|
||||
},
|
||||
default: {
|
||||
title: msg`Configuration Error`,
|
||||
description: msg`Something went wrong while rendering the document, please try again or contact our support.`,
|
||||
},
|
||||
} satisfies Record<string, { title: MessageDescriptor; description: MessageDescriptor }>;
|
||||
@@ -1,2 +1,8 @@
|
||||
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
|
||||
// Keep these two constants in sync.
|
||||
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
|
||||
export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0';
|
||||
|
||||
/**
|
||||
* Changing this will require large testing.
|
||||
*/
|
||||
export const PDF_IMAGE_RENDER_SCALE = 2;
|
||||
|
||||
@@ -242,18 +242,13 @@ export const run = async ({
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
||||
const newData = await tx.documentData.findFirstOrThrow({
|
||||
await tx.envelopeItem.update({
|
||||
where: {
|
||||
id: newDocumentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: oldDocumentDataId,
|
||||
envelopeId: envelope.id,
|
||||
documentDataId: oldDocumentDataId,
|
||||
},
|
||||
data: {
|
||||
data: newData.data,
|
||||
documentDataId: newDocumentDataId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"react-pdf": "^10.3.0",
|
||||
"remeda": "^2.32.0",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
|
||||
@@ -162,8 +162,8 @@ export const detectFieldsFromPdf = async ({
|
||||
// Mask existing fields on the image
|
||||
const maskedImage = await maskFieldsOnImage({
|
||||
image: page.image,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
width: page.scaledWidth,
|
||||
height: page.scaledHeight,
|
||||
fields: fieldsOnPage,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import pMap from 'p-map';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
|
||||
import type { ExportFormat } from 'skia-canvas';
|
||||
import { Canvas, Image, Path2D } from 'skia-canvas';
|
||||
|
||||
import { PDF_IMAGE_RENDER_SCALE } from '../../constants/pdf-viewer';
|
||||
|
||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||
globalThis.Path2D = Path2D;
|
||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||
@@ -42,10 +46,17 @@ class SkiaCanvasFactory {
|
||||
|
||||
export type PdfToImagesOptions = {
|
||||
scale?: number;
|
||||
|
||||
/**
|
||||
* The format of the images to return.
|
||||
*
|
||||
* Defaults to 'jpeg'.
|
||||
*/
|
||||
imageFormat?: ExportFormat;
|
||||
};
|
||||
|
||||
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
||||
const { scale = 2 } = options;
|
||||
const { scale = PDF_IMAGE_RENDER_SCALE, imageFormat = 'jpeg' } = options;
|
||||
|
||||
const task = await pdfjsLib.getDocument({
|
||||
data: pdfBytes,
|
||||
@@ -56,37 +67,7 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
|
||||
|
||||
const images = await pMap(
|
||||
Array.from({ length: pdf.numPages }),
|
||||
async (_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = new Canvas(viewport.width, viewport.height);
|
||||
canvas.gpu = false;
|
||||
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||
canvas,
|
||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
const result = {
|
||||
pageNumber,
|
||||
image: await canvas.toBuffer('jpeg'),
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
mimeType: 'image/jpeg',
|
||||
};
|
||||
|
||||
void page.cleanup();
|
||||
|
||||
return result;
|
||||
},
|
||||
async (_, pageIndex) => getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat }),
|
||||
{ concurrency: 10 },
|
||||
);
|
||||
|
||||
@@ -95,3 +76,85 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
|
||||
|
||||
return images;
|
||||
};
|
||||
|
||||
export type PdfToImageOptions = {
|
||||
scale?: number;
|
||||
pageIndex: number;
|
||||
|
||||
/**
|
||||
* The format of the image to return.
|
||||
* Defaults to 'jpeg'.
|
||||
*/
|
||||
imageFormat?: ExportFormat;
|
||||
};
|
||||
|
||||
export const pdfToImage = async (pdfBytes: Uint8Array, options: PdfToImageOptions) => {
|
||||
const { scale = PDF_IMAGE_RENDER_SCALE, pageIndex, imageFormat = 'jpeg' } = options;
|
||||
|
||||
if (pageIndex !== undefined && pageIndex < 0) {
|
||||
throw new Error('Page index must be greater than 0');
|
||||
}
|
||||
|
||||
const task = await pdfjsLib.getDocument({
|
||||
data: pdfBytes,
|
||||
CanvasFactory: SkiaCanvasFactory,
|
||||
});
|
||||
|
||||
const pdf = await task.promise;
|
||||
|
||||
const image = await getPdfPageAsImage({ pdf, pageIndex, scale, imageFormat });
|
||||
|
||||
void pdf.destroy().catch((e) => console.error(e));
|
||||
void task.destroy().catch((e) => console.error(e));
|
||||
|
||||
return image;
|
||||
};
|
||||
|
||||
type GetPdfPageAsImageOptions = {
|
||||
pdf: PDFDocumentProxy;
|
||||
pageIndex: number;
|
||||
scale: number;
|
||||
imageFormat: ExportFormat;
|
||||
};
|
||||
|
||||
const getPdfPageAsImage = async ({
|
||||
pdf,
|
||||
pageIndex,
|
||||
scale,
|
||||
imageFormat,
|
||||
}: GetPdfPageAsImageOptions) => {
|
||||
const page = await pdf.getPage(pageIndex + 1);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = new Canvas(viewport.width, viewport.height);
|
||||
canvas.gpu = false;
|
||||
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||
canvas,
|
||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
const originalViewport = page.getViewport({ scale: 1 });
|
||||
|
||||
const result = {
|
||||
pageIndex,
|
||||
pageNumber: pageIndex + 1,
|
||||
image: await canvas.toBuffer(imageFormat),
|
||||
originalWidth: originalViewport.width,
|
||||
originalHeight: originalViewport.height,
|
||||
scale,
|
||||
scaledWidth: Math.floor(viewport.width),
|
||||
scaledHeight: Math.floor(viewport.height),
|
||||
mimeType: 'image/jpeg',
|
||||
};
|
||||
|
||||
void page.cleanup();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -310,7 +310,7 @@ export const sendDocument = async ({
|
||||
|
||||
const injectFormValuesIntoDocument = async (
|
||||
envelope: Envelope,
|
||||
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: DocumentData },
|
||||
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: Omit<DocumentData, 'metadata'> },
|
||||
) => {
|
||||
const file = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDocumentDataMetaSchema = z.object({
|
||||
// Could store other things such as PDF size, etc here.
|
||||
pages: z
|
||||
.object({
|
||||
originalWidth: z.number().describe('Original PDF page width'),
|
||||
originalHeight: z.number().describe('Original PDF page height'),
|
||||
scale: z.number().describe('The scale applied to the width/height of the PDF page'),
|
||||
scaledWidth: z.number().describe('Scaled PDF page image width'),
|
||||
scaledHeight: z.number().describe('Scaled PDF page image height'),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type TDocumentDataMeta = z.infer<typeof ZDocumentDataMetaSchema>;
|
||||
|
||||
export type DocumentDataVersion = 'initial' | 'current';
|
||||
@@ -1,13 +1,19 @@
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { DocumentDataType } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import pMap from 'p-map';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
|
||||
import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { pdfToImages } from '../../server-only/ai/pdf-to-images';
|
||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
|
||||
import { getEnvelopeItemPageImageS3Key } from '../../utils/envelope-images';
|
||||
import { uploadS3File } from './server-actions';
|
||||
|
||||
type File = {
|
||||
@@ -41,7 +47,67 @@ export const putPdfFileServerSide = async (file: File) => {
|
||||
|
||||
const { type, data } = await putFileServerSide(file);
|
||||
|
||||
return await createDocumentData({ type, data });
|
||||
const newDocumentData = await createDocumentData({ type, data });
|
||||
|
||||
void extractAndStorePdfImages(arrayBuffer, newDocumentData.id);
|
||||
|
||||
return newDocumentData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract and stores page images and metadata to S3.
|
||||
*/
|
||||
export const extractAndStorePdfImages = async (
|
||||
arrayBuffer: ArrayBuffer,
|
||||
documentDataId: string,
|
||||
) => {
|
||||
const images = await pdfToImages(new Uint8Array(arrayBuffer));
|
||||
|
||||
const pageMetadata = images.map((image) => ({
|
||||
originalWidth: image.originalWidth,
|
||||
originalHeight: image.originalHeight,
|
||||
scale: image.scale,
|
||||
scaledWidth: image.scaledWidth,
|
||||
scaledHeight: image.scaledHeight,
|
||||
}));
|
||||
|
||||
const documentDataMetadata = ZDocumentDataMetaSchema.parse({
|
||||
pages: pageMetadata,
|
||||
} satisfies TDocumentDataMeta);
|
||||
|
||||
const updatedDocumentData = await prisma.documentData.update({
|
||||
where: { id: documentDataId },
|
||||
data: {
|
||||
metadata: documentDataMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
env('NEXT_PUBLIC_UPLOAD_TRANSPORT') === 's3' &&
|
||||
updatedDocumentData.type === DocumentDataType.S3_PATH
|
||||
) {
|
||||
await pMap(
|
||||
images,
|
||||
async (image) => {
|
||||
const imageBlob = new Blob([new Uint8Array(image.image)], { type: 'image/jpeg' });
|
||||
|
||||
const pageIndex = image.pageIndex;
|
||||
|
||||
const s3Key = getEnvelopeItemPageImageS3Key(updatedDocumentData.data, pageIndex);
|
||||
|
||||
const imageFile = new File([imageBlob], `${pageIndex}.jpeg`, {
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
|
||||
const { key } = await uploadS3File(imageFile, s3Key);
|
||||
|
||||
return key;
|
||||
},
|
||||
{ concurrency: 100 },
|
||||
);
|
||||
}
|
||||
|
||||
return pageMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -63,10 +129,14 @@ export const putNormalizedPdfFileServerSide = async (
|
||||
arrayBuffer: async () => Promise.resolve(normalized),
|
||||
});
|
||||
|
||||
return await createDocumentData({
|
||||
const newDocumentData = await createDocumentData({
|
||||
type: documentData.type,
|
||||
data: documentData.data,
|
||||
});
|
||||
|
||||
void extractAndStorePdfImages(normalized, newDocumentData.id);
|
||||
|
||||
return newDocumentData;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,13 +91,13 @@ export const getPresignGetUrl = async (key: string) => {
|
||||
/**
|
||||
* Uploads a file to S3.
|
||||
*/
|
||||
export const uploadS3File = async (file: File) => {
|
||||
export const uploadS3File = async (file: File, keyOverride?: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
// Get the basename and extension for the file
|
||||
const { name, ext } = path.parse(file.name);
|
||||
|
||||
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
|
||||
const key = keyOverride ?? `${alphaid(12)}/${slugify(name)}${ext}`;
|
||||
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
@@ -124,6 +124,28 @@ export const deleteS3File = async (key: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Be careful about using this function as we don't allow the
|
||||
* frontend to ever pull a file from S3 directly.
|
||||
*/
|
||||
export const UNSAFE_getS3File = async (key: string) => {
|
||||
// Additional safeguard to prevent path traversal.
|
||||
if (key.includes('..') || key.startsWith('/')) {
|
||||
throw new Error('Invalid S3 key');
|
||||
}
|
||||
|
||||
const client = getS3Client();
|
||||
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
return response.Body;
|
||||
};
|
||||
|
||||
const getS3Client = () => {
|
||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import type { DocumentDataVersion } from '../types/document-data';
|
||||
|
||||
export type EnvelopeItemPageImageUrlOptions = {
|
||||
envelopeId: string;
|
||||
envelopeItemId: string;
|
||||
documentDataId: string;
|
||||
pageIndex: number;
|
||||
token: string | undefined;
|
||||
presignToken?: string | undefined;
|
||||
version: DocumentDataVersion;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the URL for fetching a single page of a PDF as an image.
|
||||
*/
|
||||
export const getEnvelopeItemPageImageUrl = (options: EnvelopeItemPageImageUrlOptions): string => {
|
||||
const { envelopeId, envelopeItemId, documentDataId, pageIndex, token, presignToken, version } =
|
||||
options;
|
||||
|
||||
const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/${pageIndex}/image.jpeg`;
|
||||
|
||||
// Recipient token endpoint.
|
||||
if (token) {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`;
|
||||
}
|
||||
|
||||
// Endpoint authenticated by session or presigned token.
|
||||
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`;
|
||||
|
||||
if (presignToken) {
|
||||
return `${baseUrl}?presignToken=${presignToken}`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
export type EnvelopeItemMetaUrlOptions = {
|
||||
envelopeId: string;
|
||||
token: string | undefined;
|
||||
presignToken?: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the URL for fetching envelope metadata (page counts and dimensions).
|
||||
*/
|
||||
export const getEnvelopeItemMetaUrl = (options: EnvelopeItemMetaUrlOptions): string => {
|
||||
const { envelopeId, token, presignToken } = options;
|
||||
|
||||
// Recipient token endpoint.
|
||||
if (token) {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelope/${envelopeId}/meta`;
|
||||
}
|
||||
|
||||
// Endpoint authenticated by session or presigned token.
|
||||
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/meta`;
|
||||
|
||||
if (presignToken) {
|
||||
return `${baseUrl}?presignToken=${presignToken}`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
export const getEnvelopeItemPageImageS3Key = (
|
||||
documentDataId: string,
|
||||
pageIndex: number,
|
||||
): string => {
|
||||
// Sanity check incase someone passes in a base64 PDF somehow.
|
||||
if (documentDataId.length > 100) {
|
||||
throw new Error('Document data is too long to be a valid S3 key');
|
||||
}
|
||||
|
||||
const baseKey = documentDataId.split('/')[0];
|
||||
|
||||
return `${baseKey}/${pageIndex}.jpeg`;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentData" ADD COLUMN "metadata" JSONB;
|
||||
@@ -487,11 +487,13 @@ enum DocumentSigningOrder {
|
||||
SEQUENTIAL
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentDataMetaSchema } from '@documenso/lib/types/document-data';"])
|
||||
model DocumentData {
|
||||
id String @id @default(cuid())
|
||||
type DocumentDataType
|
||||
data String
|
||||
initialData String
|
||||
metadata Json? /// [DocumentDataMeta] @zod.custom.use(ZDocumentDataMetaSchema)
|
||||
envelopeItem EnvelopeItem?
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
@@ -4,6 +4,7 @@ import type {
|
||||
TDocumentAuthOptions,
|
||||
TRecipientAuthOptions,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
|
||||
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
||||
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
|
||||
@@ -29,6 +30,7 @@ declare global {
|
||||
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
|
||||
|
||||
type DefaultRecipient = TDefaultRecipient;
|
||||
type DocumentDataMeta = TDocumentDataMeta;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,14 @@ export const getDocumentByTokenRoute = authenticatedProcedure
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
documentData: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,12 @@ export const ZGetDocumentByTokenRequestSchema = z.object({
|
||||
});
|
||||
|
||||
export const ZGetDocumentByTokenResponseSchema = z.object({
|
||||
documentData: DocumentDataSchema,
|
||||
documentData: DocumentDataSchema.pick({
|
||||
id: true,
|
||||
type: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export type TGetDocumentByTokenRequest = z.infer<typeof ZGetDocumentByTokenRequestSchema>;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export type VirtualListOptions = {
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
constraintRef?: React.RefObject<HTMLElement | null>;
|
||||
itemCount: number;
|
||||
itemSize: number | ((index: number, constraintWidth: number) => number);
|
||||
overscan?: number;
|
||||
};
|
||||
|
||||
export type VirtualItem = {
|
||||
index: number;
|
||||
start: number;
|
||||
size: number;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type VirtualListResult = {
|
||||
virtualItems: VirtualItem[];
|
||||
totalSize: number;
|
||||
constraintWidth: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A minimal list virtualizer hook that supports fixed item sizes and external scroll containers.
|
||||
*
|
||||
* @param options - Configuration options for the virtual list
|
||||
* @returns Virtual items to render, total size, and constraint width
|
||||
*/
|
||||
export const useVirtualList = (options: VirtualListOptions): VirtualListResult => {
|
||||
const { scrollRef, constraintRef, itemCount, itemSize, overscan = 3 } = options;
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [constraintWidth, setConstraintWidth] = useState(0);
|
||||
|
||||
// Track constraint element width with ResizeObserver
|
||||
useEffect(() => {
|
||||
const el = constraintRef?.current;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (entry) {
|
||||
setConstraintWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
// Set initial width
|
||||
setConstraintWidth(el.getBoundingClientRect().width);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [constraintRef]);
|
||||
|
||||
// Track scroll container dimensions with ResizeObserver
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (entry) {
|
||||
setViewportHeight(entry.contentRect.height);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
// Set initial height
|
||||
setViewportHeight(el.getBoundingClientRect().height);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef]);
|
||||
|
||||
// Handle scroll events
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollTop(el.scrollTop);
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// Set initial scroll position
|
||||
setScrollTop(el.scrollTop);
|
||||
|
||||
return () => el.removeEventListener('scroll', handleScroll);
|
||||
}, [scrollRef]);
|
||||
|
||||
// Get item size helper
|
||||
const getItemSize = useCallback(
|
||||
(index: number): number => {
|
||||
if (typeof itemSize === 'function') {
|
||||
return itemSize(index, constraintWidth);
|
||||
}
|
||||
|
||||
return itemSize;
|
||||
},
|
||||
[itemSize, constraintWidth],
|
||||
);
|
||||
|
||||
// Precompute item offsets for O(1) lookup
|
||||
const { offsets, totalSize } = useMemo(() => {
|
||||
const result: number[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
result.push(offset);
|
||||
offset += getItemSize(i);
|
||||
}
|
||||
|
||||
return { offsets: result, totalSize: offset };
|
||||
}, [itemCount, getItemSize]);
|
||||
|
||||
// Binary search to find the first visible item
|
||||
const findStartIndex = useCallback(
|
||||
(scrollTop: number): number => {
|
||||
let low = 0;
|
||||
let high = itemCount - 1;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const offset = offsets[mid];
|
||||
|
||||
if (offset < scrollTop) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, low - 1);
|
||||
},
|
||||
[offsets, itemCount],
|
||||
);
|
||||
|
||||
// Calculate virtual items to render
|
||||
const virtualItems = useMemo((): VirtualItem[] => {
|
||||
if (itemCount === 0 || constraintWidth === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const startIndex = findStartIndex(scrollTop);
|
||||
const items: VirtualItem[] = [];
|
||||
|
||||
// Apply overscan before visible area
|
||||
const overscanStart = Math.max(0, startIndex - overscan);
|
||||
|
||||
// Find items within the visible area + overscan
|
||||
for (let i = overscanStart; i < itemCount; i++) {
|
||||
const start = offsets[i];
|
||||
const size = getItemSize(i);
|
||||
|
||||
// Stop if we've gone past the visible area + overscan
|
||||
if (start > scrollTop + viewportHeight) {
|
||||
// Add overscan items after visible area
|
||||
const overscanEnd = Math.min(itemCount, i + overscan);
|
||||
|
||||
for (let j = i; j < overscanEnd; j++) {
|
||||
items.push({
|
||||
index: j,
|
||||
start: offsets[j],
|
||||
size: getItemSize(j),
|
||||
key: `virtual-item-${j}`,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
items.push({
|
||||
index: i,
|
||||
start,
|
||||
size,
|
||||
key: `virtual-item-${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
itemCount,
|
||||
constraintWidth,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
overscan,
|
||||
offsets,
|
||||
getItemSize,
|
||||
findStartIndex,
|
||||
]);
|
||||
|
||||
return {
|
||||
virtualItems,
|
||||
totalSize,
|
||||
constraintWidth,
|
||||
};
|
||||
};
|
||||
@@ -66,14 +66,13 @@
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"react": "^18",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-rnd": "^10.5.2",
|
||||
"remeda": "^2.32.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import {
|
||||
type LoadedPDFDocument,
|
||||
type OnPDFViewerPageClick,
|
||||
PDFViewer,
|
||||
type PDFViewerProps,
|
||||
} from './base';
|
||||
|
||||
export { PDFViewer, type LoadedPDFDocument, type OnPDFViewerPageClick, type PDFViewerProps };
|
||||
|
||||
export default PDFViewer;
|
||||
@@ -1,290 +0,0 @@
|
||||
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 { base64 } from '@scure/base';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { type PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useToast } from '../use-toast';
|
||||
|
||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
|
||||
/**
|
||||
* This imports the worker from the `pdfjs-dist` package.
|
||||
* Wrapped in typeof window check to prevent SSR evaluation.
|
||||
*/
|
||||
if (typeof window !== 'undefined') {
|
||||
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/`,
|
||||
};
|
||||
|
||||
export type OnPDFViewerPageClick = (_event: {
|
||||
pageNumber: number;
|
||||
numPages: number;
|
||||
originalEvent: React.MouseEvent<HTMLDivElement, MouseEvent>;
|
||||
pageHeight: number;
|
||||
pageWidth: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
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 PDFViewerProps = {
|
||||
className?: string;
|
||||
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
token: string | undefined;
|
||||
presignToken?: string | undefined;
|
||||
version: 'original' | 'signed';
|
||||
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
||||
onPageClick?: OnPDFViewerPageClick;
|
||||
overrideData?: string;
|
||||
customPageRenderer?: React.FunctionComponent;
|
||||
[key: string]: unknown;
|
||||
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
|
||||
|
||||
export const PDFViewer = ({
|
||||
className,
|
||||
envelopeItem,
|
||||
token,
|
||||
presignToken,
|
||||
version,
|
||||
onDocumentLoad,
|
||||
onPageClick,
|
||||
overrideData,
|
||||
customPageRenderer,
|
||||
...props
|
||||
}: PDFViewerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
|
||||
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(
|
||||
overrideData ? base64.decode(overrideData) : null,
|
||||
);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
const isLoading = isDocumentBytesLoading || !documentBytes;
|
||||
|
||||
const envelopeItemFile = useMemo(() => {
|
||||
if (!documentBytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: documentBytes,
|
||||
};
|
||||
}, [documentBytes]);
|
||||
|
||||
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
|
||||
setNumPages(doc.numPages);
|
||||
onDocumentLoad?.(doc);
|
||||
};
|
||||
|
||||
const onDocumentPageClick = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
pageNumber: number,
|
||||
) => {
|
||||
const $el = event.target instanceof HTMLElement ? event.target : null;
|
||||
|
||||
if (!$el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width, top, left } = $page.getBoundingClientRect();
|
||||
|
||||
const pageX = event.clientX - left;
|
||||
const pageY = event.clientY - top;
|
||||
|
||||
if (onPageClick) {
|
||||
void onPageClick({
|
||||
pageNumber,
|
||||
numPages,
|
||||
originalEvent: event,
|
||||
pageHeight: height,
|
||||
pageWidth: width,
|
||||
pageX,
|
||||
pageY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideData) {
|
||||
const bytes = base64.decode(overrideData);
|
||||
|
||||
setDocumentBytes(bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDocumentBytes = async () => {
|
||||
try {
|
||||
setIsDocumentBytesLoading(true);
|
||||
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'view',
|
||||
envelopeItem: envelopeItem,
|
||||
token,
|
||||
presignToken,
|
||||
});
|
||||
|
||||
const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
setDocumentBytes(new Uint8Array(bytes));
|
||||
|
||||
setIsDocumentBytesLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while loading the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void fetchDocumentBytes();
|
||||
}, [envelopeItem.envelopeId, envelopeItem.id, token, version, toast, overrideData]);
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
|
||||
)}
|
||||
>
|
||||
<PDFLoader />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PDFDocument
|
||||
file={envelopeItemFile}
|
||||
className={cn('w-full overflow-hidden 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="overflow-hidden rounded border border-border will-change-transform">
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
renderMode={customPageRenderer ? 'custom' : 'canvas'}
|
||||
customRenderer={customPageRenderer}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
<p className="my-2 text-center text-[11px] text-muted-foreground/80">
|
||||
<Trans>
|
||||
Page {i + 1} of {numPages}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PDFViewer;
|
||||
@@ -1 +0,0 @@
|
||||
export * from './base';
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ClientOnly } from '../../components/client-only';
|
||||
|
||||
import { PDFViewer, type PDFViewerProps } from './base.client';
|
||||
|
||||
export const PDFViewerLazy = (props: PDFViewerProps) => {
|
||||
return <ClientOnly fallback={<div>Loading...</div>}>{() => <PDFViewer {...props} />}</ClientOnly>;
|
||||
};
|
||||
Reference in New Issue
Block a user