diff --git a/apps/docs/solutions/esign/configuration.mdx b/apps/docs/solutions/esign/configuration.mdx index 792c62989..67ce8ee8f 100644 --- a/apps/docs/solutions/esign/configuration.mdx +++ b/apps/docs/solutions/esign/configuration.mdx @@ -232,6 +232,78 @@ onFieldsDiscovered={(fields) => { }} ``` +## PDF support + +Render PDF documents in eSign by passing the `pdf` prop. This forwards the PDF module config to SuperDoc internally. The component handles format detection automatically — when `pdf` is provided with a URL source, it tells SuperDoc to use the PDF renderer. + +```jsx +import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs'; + +const pdfWorkerUrl = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + + { + // Browser-side download — no server needed + const response = await fetch(data.documentSource); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = data.fileName || "document.pdf"; + a.click(); + URL.revokeObjectURL(url); + }} + onSubmit={handleSubmit} +/> +``` + +### `pdf` prop + +| Property | Type | Description | +|----------|------|-------------| +| `pdfLib` | `object` | **Required.** The `pdfjs-dist` library instance | +| `workerSrc` | `string` | PDF.js worker source URL (falls back to CDN when omitted) | +| `setWorker` | `boolean` | Whether to auto-configure the pdf.js worker | +| `textLayer` | `boolean` | Enable text layer for text selection (default: `false`) | +| `outputScale` | `number` | Canvas render scale — higher values produce sharper output | + +### How it works + +- SuperDoc fires `onPdfDocumentReady` instead of `onReady` for PDFs. The eSign component handles both callbacks, so it works regardless of document type. +- PDFs don't have a ProseMirror editor, so `fields.document` has no effect. Use `fields.signer` for interactive fields like checkboxes and signatures. +- Use `viewOptions: { layout: 'print' }` for fixed page widths that match the original PDF layout. + + +Memoize the `pdf` config object to avoid unnecessary re-initialization. Use `useMemo` in React or a stable reference from the caller. + + ## Styling The component can be styled using standard CSS. Optionally import default styles: diff --git a/packages/esign/demo/package.json b/packages/esign/demo/package.json index 6121ae118..30971640c 100644 --- a/packages/esign/demo/package.json +++ b/packages/esign/demo/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@superdoc-dev/esign": "workspace:*", + "pdfjs-dist": "5.4.624", "react": "catalog:", "react-dom": "catalog:", "signature_pad": "^5.1.1", diff --git a/packages/esign/demo/src/App.css b/packages/esign/demo/src/App.css index 5ac150c88..c0eecaee5 100644 --- a/packages/esign/demo/src/App.css +++ b/packages/esign/demo/src/App.css @@ -472,6 +472,8 @@ header { .superdoc-esign-document-viewer { background: #f9fafb; + display: flex; + justify-content: center; } .superdoc-esign-controls { diff --git a/packages/esign/demo/src/App.tsx b/packages/esign/demo/src/App.tsx index 3a763151b..3b840961f 100644 --- a/packages/esign/demo/src/App.tsx +++ b/packages/esign/demo/src/App.tsx @@ -1,242 +1,31 @@ -import { useState, useRef } from 'react'; -import SuperDocESign, { textToImageDataUrl } from '@superdoc-dev/esign'; -import type { SubmitData, SigningState, FieldChange, DownloadData, SuperDocESignHandle } from '@superdoc-dev/esign'; -import CustomSignature from './CustomSignature'; +import { useState } from 'react'; +import type { SubmitData, SigningState, FieldChange } from '@superdoc-dev/esign'; +import DocxTab from './DocxTab'; +import PdfTab from './PdfTab'; import 'superdoc/style.css'; import './App.css'; -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; - -const documentSource = - 'https://storage.googleapis.com/public_static_hosting/public_demo_docs/service_agreement_with_table.docx'; - -// Document field definitions with labels -interface DocumentFieldConfig { - id: string; - value: string | string[][]; - type?: 'text' | 'table'; - label?: string; - readOnly?: boolean; -} - -const signerFieldsConfig = [ - { - id: '789012', - type: 'signature' as const, - label: 'Your Signature', - validation: { required: true }, - component: CustomSignature, - }, - { - id: '1', - type: 'checkbox' as const, - label: 'I accept the terms and conditions', - validation: { required: true }, - }, - { - id: '2', - type: 'checkbox' as const, - label: 'Send me a copy of the agreement', - validation: { required: false }, - }, -]; - -const signatureFieldIds = new Set( - signerFieldsConfig.filter((field) => field.type === 'signature').map((field) => field.id), -); - -const toSignatureImageValue = (value: SubmitData['signerFields'][number]['value']) => { - if (value === null || value === undefined) return null; - if (typeof value === 'string' && value.startsWith('data:image/')) return value; - return textToImageDataUrl(String(value)); -}; - -const mapSignerFieldsWithType = ( - fields: Array<{ id: string; value: SubmitData['signerFields'][number]['value'] }>, - signatureType: 'signature' | 'image', -) => - fields.map((field) => { - if (!signatureFieldIds.has(field.id)) { - return field; - } - - return { - ...field, - type: signatureType, - value: toSignatureImageValue(field.value), - }; - }); - -// Helper to download a response blob as a file -const downloadBlob = async (response: Response, fileName: string) => { - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); -}; - -// Document field definitions with labels -const documentFieldsConfig: DocumentFieldConfig[] = [ - { - id: '123456', - label: 'Date', - value: new Date().toLocaleDateString(), - readOnly: true, - type: 'text', - }, - { - id: '234567', - label: 'Full Name', - value: 'John Doe', - readOnly: false, - type: 'text', - }, - { - id: '345678', - label: 'Company', - value: 'SuperDoc', - readOnly: false, - type: 'text', - }, - { id: '456789', label: 'Plan', value: 'Premium', readOnly: false, type: 'text' as const }, - { id: '567890', label: 'State', value: 'CA', readOnly: false, type: 'text' as const }, - { - id: '678901', - label: 'Address', - value: '123 Main St, Anytown, USA', - readOnly: false, - type: 'text', - }, - { - id: '238312460', - label: 'User responsibilities', - value: [[' - Provide accurate and complete information']], - readOnly: false, - type: 'table', - }, -]; +type DemoTab = 'docx' | 'pdf'; export function App() { + const [activeTab, setActiveTab] = useState('docx'); const [submitted, setSubmitted] = useState(false); const [submitData, setSubmitData] = useState(null); const [events, setEvents] = useState([]); - - // Stable eventId that persists across renders const [eventId] = useState(() => `demo-${Date.now()}`); - // Ref to the esign component - const esignRef = useRef(null); - - // State for document field values - const [documentFields, setDocumentFields] = useState>(() => - Object.fromEntries(documentFieldsConfig.map((f) => [f.id, f.value])), - ); - - const updateDocumentField = (id: string, value: string | string[][]) => { - const fieldConfig = documentFieldsConfig.find((f) => f.id === id); - setDocumentFields((prev) => ({ ...prev, [id]: value })); - esignRef.current?.updateFieldInDocument({ id, value, type: fieldConfig?.type }); - }; - - // Helper to get table rows as 2D array (for table fields) - const getTableRows = (fieldId: string): string[][] => { - const value = documentFields[fieldId]; - return Array.isArray(value) ? value : []; - }; - const log = (msg: string) => { const time = new Date().toLocaleTimeString(); console.log(`[${time}] ${msg}`); setEvents((prev) => [...prev.slice(-4), `${time} - ${msg}`]); }; - const handleSubmit = async (data: SubmitData) => { - log('⏳ Signing document...'); - console.log('Submit data:', data); - - try { - const signerFields = mapSignerFieldsWithType(data.signerFields, 'signature'); - - const response = await fetch(`${API_BASE_URL}/v1/sign`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - document: { url: documentSource }, - documentFields: data.documentFields, - signerFields, - auditTrail: data.auditTrail, - eventId: data.eventId, - certificate: { enable: true }, - metadata: { - company: documentFields['345678'], - plan: documentFields['456789'], - }, - fileName: `signed_agreement_${data.eventId}.pdf`, - signatureMode: 'sign', - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(error || 'Failed to sign document'); - } - - await downloadBlob(response, `signed_agreement_${data.eventId}.pdf`); - - log('✓ Document signed and downloaded!'); - setSubmitted(true); - setSubmitData(data); - } catch (error) { - console.error('Error signing document:', error); - log(`✗ Signing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - }; - - const handleDownload = async (data: DownloadData) => { - try { - if (typeof data.documentSource !== 'string') { - log('Download requires a document URL.'); - return; - } - - const signerFields = mapSignerFieldsWithType(data.fields.signer, 'image'); - - const response = await fetch(`${API_BASE_URL}/v1/download`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - document: { url: data.documentSource }, - fields: { - ...data.fields, - signer: signerFields, - }, - fileName: data.fileName, - signatureMode: 'annotate', - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(error || 'Failed to annotate document'); - } - - await downloadBlob(response, data.fileName || 'document.pdf'); - log('✓ Downloaded PDF'); - } catch (error) { - console.error('Error processing document:', error); - log('Download failed'); - } - }; - const handleStateChange = (state: SigningState) => { if (state.scrolled && !events.some((e) => e.includes('Scrolled'))) { - log('↓ Scrolled to bottom'); + log('Scrolled to bottom'); } if (state.isValid && !events.some((e) => e.includes('Ready'))) { - log('✓ Ready to submit'); + log('Ready to submit'); } console.log('State:', state); }; @@ -250,6 +39,17 @@ export function App() { console.log('Field change:', field); }; + const handleSubmitted = (data: SubmitData) => { + setSubmitted(true); + setSubmitData(data); + }; + + const switchTab = (tab: DemoTab) => { + setActiveTab(tab); + setSubmitted(false); + setEvents([]); + }; + return (
@@ -275,26 +75,8 @@ export function App() { borderRadius: '8px', }} > -
-

Agreement Signed!

+

Submitted!

Event ID: {submitData?.eventId}

- {submitData?.signerFields.find((f) => f.id === 'signature') && ( -
-

Signature:

-
- {submitData.signerFields.find((f) => f.id === 'signature')?.value} -
-
- )}
+ ) : activeTab === 'docx' ? ( + ) : ( - <> -

Employment Agreement

-

- Use the document toolbar to download the current agreement at any time. -

- -
- {/* Main content */} -
- ({ - id: f.id, - value: documentFields[f.id], - type: f.type, - })), - signer: signerFieldsConfig, - }} - download={{ label: 'Download PDF' }} - onSubmit={handleSubmit} - onDownload={handleDownload} - onStateChange={handleStateChange} - onFieldChange={handleFieldChange} - documentHeight='500px' - /> - - {/* Event Log */} - {events.length > 0 && ( -
-
- EVENT LOG -
- {events.map((evt, i) => ( -
- {evt} -
- ))} -
- )} -
- - {/* Right Sidebar - Document Fields */} -
-

Document Fields

-
- {documentFieldsConfig.map((field) => ( -
- - {field.type === 'table' ? ( -
- {getTableRows(field.id).map((row, rowIndex) => ( -
- {row.map((cellValue, cellIndex) => ( - { - const rows = [...getTableRows(field.id)]; - rows[rowIndex] = [...rows[rowIndex]]; - rows[rowIndex][cellIndex] = e.target.value; - updateDocumentField(field.id, rows); - }} - style={{ - flex: 1, - padding: '8px 10px', - fontSize: '14px', - border: '1px solid #d1d5db', - borderRadius: '6px', - boxSizing: 'border-box', - }} - /> - ))} - -
- ))} - -
- ) : ( - updateDocumentField(field.id, e.target.value)} - readOnly={field.readOnly} - style={{ - width: '100%', - padding: '8px 10px', - fontSize: '14px', - border: '1px solid #d1d5db', - borderRadius: '6px', - background: field.readOnly ? '#f3f4f6' : 'white', - color: field.readOnly ? '#6b7280' : '#111827', - cursor: field.readOnly ? 'not-allowed' : 'text', - boxSizing: 'border-box', - }} - /> - )} -
- ))} -
-
-
- + )} ); diff --git a/packages/esign/demo/src/DocxTab.tsx b/packages/esign/demo/src/DocxTab.tsx new file mode 100644 index 000000000..605356adf --- /dev/null +++ b/packages/esign/demo/src/DocxTab.tsx @@ -0,0 +1,367 @@ +import { useState, useRef } from 'react'; +import SuperDocESign, { textToImageDataUrl } from '@superdoc-dev/esign'; +import type { SubmitData, DownloadData, SuperDocESignHandle, SigningState, FieldChange } from '@superdoc-dev/esign'; +import CustomSignature from './CustomSignature'; +import TabHeader from './TabHeader'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; + +const documentSource = + 'https://storage.googleapis.com/public_static_hosting/public_demo_docs/service_agreement_with_table.docx'; + +interface DocumentFieldConfig { + id: string; + value: string | string[][]; + type?: 'text' | 'table'; + label?: string; + readOnly?: boolean; +} + +const signerFieldsConfig = [ + { + id: '789012', + type: 'signature' as const, + label: 'Your Signature', + validation: { required: true }, + component: CustomSignature, + }, + { + id: '1', + type: 'checkbox' as const, + label: 'I accept the terms and conditions', + validation: { required: true }, + }, + { + id: '2', + type: 'checkbox' as const, + label: 'Send me a copy of the agreement', + validation: { required: false }, + }, +]; + +const signatureFieldIds = new Set( + signerFieldsConfig.filter((field) => field.type === 'signature').map((field) => field.id), +); + +const toSignatureImageValue = (value: SubmitData['signerFields'][number]['value']) => { + if (value === null || value === undefined) return null; + if (typeof value === 'string' && value.startsWith('data:image/')) return value; + return textToImageDataUrl(String(value)); +}; + +const mapSignerFieldsWithType = ( + fields: Array<{ id: string; value: SubmitData['signerFields'][number]['value'] }>, + signatureType: 'signature' | 'image', +) => + fields.map((field) => { + if (!signatureFieldIds.has(field.id)) { + return field; + } + return { + ...field, + type: signatureType, + value: toSignatureImageValue(field.value), + }; + }); + +const downloadBlob = async (response: Response, fileName: string) => { + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +}; + +const documentFieldsConfig: DocumentFieldConfig[] = [ + { id: '123456', label: 'Date', value: new Date().toLocaleDateString(), readOnly: true, type: 'text' }, + { id: '234567', label: 'Full Name', value: 'John Doe', readOnly: false, type: 'text' }, + { id: '345678', label: 'Company', value: 'SuperDoc', readOnly: false, type: 'text' }, + { id: '456789', label: 'Plan', value: 'Premium', readOnly: false, type: 'text' }, + { id: '567890', label: 'State', value: 'CA', readOnly: false, type: 'text' }, + { id: '678901', label: 'Address', value: '123 Main St, Anytown, USA', readOnly: false, type: 'text' }, + { + id: '238312460', + label: 'User responsibilities', + value: [[' - Provide accurate and complete information']], + readOnly: false, + type: 'table', + }, +]; + +interface DocxTabProps { + eventId: string; + events: string[]; + log: (msg: string) => void; + activeTab: 'docx' | 'pdf'; + onSwitchTab: (tab: 'docx' | 'pdf') => void; + onSubmitted: (data: SubmitData) => void; + onStateChange: (state: SigningState) => void; + onFieldChange: (field: FieldChange) => void; +} + +export default function DocxTab({ + eventId, + events, + log, + activeTab, + onSwitchTab, + onSubmitted, + onStateChange, + onFieldChange, +}: DocxTabProps) { + const esignRef = useRef(null); + + const [documentFields, setDocumentFields] = useState>(() => + Object.fromEntries(documentFieldsConfig.map((f) => [f.id, f.value])), + ); + + const updateDocumentField = (id: string, value: string | string[][]) => { + const fieldConfig = documentFieldsConfig.find((f) => f.id === id); + setDocumentFields((prev) => ({ ...prev, [id]: value })); + esignRef.current?.updateFieldInDocument({ id, value, type: fieldConfig?.type }); + }; + + const getTableRows = (fieldId: string): string[][] => { + const value = documentFields[fieldId]; + return Array.isArray(value) ? value : []; + }; + + const handleSubmit = async (data: SubmitData) => { + log('Signing document...'); + console.log('Submit data:', data); + + try { + const signerFields = mapSignerFieldsWithType(data.signerFields, 'signature'); + + const response = await fetch(`${API_BASE_URL}/v1/sign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + document: { url: documentSource }, + documentFields: data.documentFields, + signerFields, + auditTrail: data.auditTrail, + eventId: data.eventId, + certificate: { enable: true }, + metadata: { + company: documentFields['345678'], + plan: documentFields['456789'], + }, + fileName: `signed_agreement_${data.eventId}.pdf`, + signatureMode: 'sign', + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to sign document'); + } + + await downloadBlob(response, `signed_agreement_${data.eventId}.pdf`); + + log('Document signed and downloaded!'); + onSubmitted(data); + } catch (error) { + console.error('Error signing document:', error); + log(`Signing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const handleDownload = async (data: DownloadData) => { + try { + if (typeof data.documentSource !== 'string') { + log('Download requires a document URL.'); + return; + } + + const signerFields = mapSignerFieldsWithType(data.fields.signer, 'image'); + + const response = await fetch(`${API_BASE_URL}/v1/download`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + document: { url: data.documentSource }, + fields: { ...data.fields, signer: signerFields }, + fileName: data.fileName, + signatureMode: 'annotate', + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to annotate document'); + } + + await downloadBlob(response, data.fileName || 'document.pdf'); + log('Downloaded PDF'); + } catch (error) { + console.error('Error processing document:', error); + log('Download failed'); + } + }; + + return ( + <> + + +
+
+ ({ + id: f.id, + value: documentFields[f.id], + type: f.type, + })), + signer: signerFieldsConfig, + }} + download={{ label: 'Download' }} + onSubmit={handleSubmit} + onDownload={handleDownload} + onStateChange={onStateChange} + onFieldChange={onFieldChange} + documentHeight='500px' + /> + + {events.length > 0 && ( +
+
+ EVENT LOG +
+ {events.map((evt, i) => ( +
+ {evt} +
+ ))} +
+ )} +
+ +
+

Document Fields

+
+ {documentFieldsConfig.map((field) => ( +
+ + {field.type === 'table' ? ( +
+ {getTableRows(field.id).map((row, rowIndex) => ( +
+ {row.map((cellValue, cellIndex) => ( + { + const rows = [...getTableRows(field.id)]; + rows[rowIndex] = [...rows[rowIndex]]; + rows[rowIndex][cellIndex] = e.target.value; + updateDocumentField(field.id, rows); + }} + style={{ + flex: 1, + padding: '8px 10px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + boxSizing: 'border-box', + }} + /> + ))} + +
+ ))} + +
+ ) : ( + updateDocumentField(field.id, e.target.value)} + readOnly={field.readOnly} + style={{ + width: '100%', + padding: '8px 10px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + background: field.readOnly ? '#f3f4f6' : 'white', + color: field.readOnly ? '#6b7280' : '#111827', + cursor: field.readOnly ? 'not-allowed' : 'text', + boxSizing: 'border-box', + }} + /> + )} +
+ ))} +
+
+
+ + ); +} diff --git a/packages/esign/demo/src/PdfTab.tsx b/packages/esign/demo/src/PdfTab.tsx new file mode 100644 index 000000000..87897b7a0 --- /dev/null +++ b/packages/esign/demo/src/PdfTab.tsx @@ -0,0 +1,115 @@ +import { useMemo } from 'react'; +import SuperDocESign from '@superdoc-dev/esign'; +import type { SubmitData, SigningState, FieldChange, DownloadData, PdfModuleConfig } from '@superdoc-dev/esign'; +import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs'; +import TabHeader from './TabHeader'; + +const pathToPDFWorker = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); +const pdfDocumentSource = 'https://storage.googleapis.com/public_static_hosting/public_demo_docs/demo%20pdf.pdf'; + +interface PdfTabProps { + eventId: string; + events: string[]; + log: (msg: string) => void; + activeTab: 'docx' | 'pdf'; + onSwitchTab: (tab: 'docx' | 'pdf') => void; + onSubmitted: (data: SubmitData) => void; + onStateChange: (state: SigningState) => void; + onFieldChange: (field: FieldChange) => void; +} + +export default function PdfTab({ + eventId, + events, + log, + activeTab, + onSwitchTab, + onSubmitted, + onStateChange, + onFieldChange, +}: PdfTabProps) { + const pdfConfig = useMemo( + () => ({ + pdfLib: pdfjsLib, + workerSrc: pathToPDFWorker as string, + setWorker: true, + outputScale: 2, + }), + [], + ); + + return ( + <> + + + { + const source = data.documentSource; + if (typeof source !== 'string') return; + const response = await fetch(source); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = data.fileName || 'document.pdf'; + a.click(); + URL.revokeObjectURL(url); + log('Downloaded PDF'); + }} + fields={{ + signer: [ + { + id: 'pdf-accept', + type: 'checkbox' as const, + label: 'I have read and agree to these rules', + validation: { required: true }, + }, + ], + }} + onSubmit={async (data) => { + log('PDF submit received'); + console.log('PDF submit data:', data); + onSubmitted(data); + }} + onStateChange={onStateChange} + onFieldChange={onFieldChange} + documentHeight='500px' + /> + + {events.length > 0 && ( +
+
EVENT LOG
+ {events.map((evt, i) => ( +
+ {evt} +
+ ))} +
+ )} + + ); +} diff --git a/packages/esign/demo/src/TabHeader.tsx b/packages/esign/demo/src/TabHeader.tsx new file mode 100644 index 000000000..c69382b38 --- /dev/null +++ b/packages/esign/demo/src/TabHeader.tsx @@ -0,0 +1,40 @@ +type DemoTab = 'docx' | 'pdf'; + +interface TabHeaderProps { + title: string; + subtitle?: string; + activeTab: DemoTab; + onSwitchTab: (tab: DemoTab) => void; +} + +export default function TabHeader({ title, subtitle, activeTab, onSwitchTab }: TabHeaderProps) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {(['docx', 'pdf'] as const).map((tab) => ( + + ))} +
+
+ ); +} diff --git a/packages/esign/src/__tests__/SuperDocESign.test.tsx b/packages/esign/src/__tests__/SuperDocESign.test.tsx index dc7aae025..9c9a83023 100644 --- a/packages/esign/src/__tests__/SuperDocESign.test.tsx +++ b/packages/esign/src/__tests__/SuperDocESign.test.tsx @@ -731,6 +731,580 @@ describe('SuperDocESign component', () => { }); }); + describe('download button', () => { + it('invokes onDownload with correct payload when download button is clicked', async () => { + const onDownload = vi.fn(); + + const { getByRole } = renderComponent({ + onDownload, + document: { + source: 'https://example.com/doc.docx', + mode: 'full', + }, + fields: { + document: [{ id: 'd1', value: 'Doc Value' }], + signer: [ + { + id: 's1', + type: 'checkbox', + label: 'Accept', + validation: { required: false }, + }, + ], + }, + download: { fileName: 'my-contract.pdf' }, + }); + + await waitForSuperDocReady(); + + const downloadButton = getByRole('button', { name: /download/i }); + await userEvent.click(downloadButton); + + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1)); + + const payload = onDownload.mock.calls[0][0]; + expect(payload).toMatchObject({ + eventId: 'evt_test', + documentSource: 'https://example.com/doc.docx', + fileName: 'my-contract.pdf', + fields: { + document: [{ id: 'd1', value: 'Doc Value' }], + signer: [{ id: 's1', value: null }], + }, + }); + }); + + it('uses default fileName when none is configured', async () => { + const onDownload = vi.fn(); + + renderComponent({ onDownload }); + + await waitForSuperDocReady(); + + const downloadButton = screen.getByRole('button', { name: /download/i }); + await userEvent.click(downloadButton); + + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1)); + expect(onDownload.mock.calls[0][0].fileName).toBe('document.pdf'); + }); + }); + + describe('isDisabled prop', () => { + it('disables submit button when isDisabled is true', async () => { + renderComponent({ isDisabled: true }); + + await waitForSuperDocReady(); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDisabled(); + }); + + it('prevents submit when isDisabled is true even if form is valid', async () => { + const onSubmit = vi.fn(); + + renderComponent({ + isDisabled: true, + onSubmit, + fields: { + signer: [ + { + id: '1', + type: 'checkbox', + label: 'Accept', + validation: { required: true }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const checkbox = screen.getByRole('checkbox'); + await userEvent.click(checkbox); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDisabled(); + }); + + it('disables signer fields when isDisabled is true', async () => { + renderComponent({ + isDisabled: true, + fields: { + signer: [ + { + id: '1', + type: 'checkbox', + label: 'Accept', + validation: { required: false }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeDisabled(); + }); + }); + + describe('className, style, and documentHeight props', () => { + it('applies custom className to container', async () => { + renderComponent({ className: 'my-custom-class' }); + + await waitForSuperDocReady(); + + const container = screen.getByTestId('superdoc-esign-document').parentElement; + expect(container?.className).toContain('my-custom-class'); + }); + + it('applies custom style to container', async () => { + renderComponent({ style: { maxWidth: '800px', border: '1px solid red' } }); + + await waitForSuperDocReady(); + + const container = screen.getByTestId('superdoc-esign-document').parentElement; + expect(container?.style.maxWidth).toBe('800px'); + expect(container?.style.border).toBe('1px solid red'); + }); + + it('applies documentHeight to scroll container', async () => { + renderComponent({ documentHeight: '400px' }); + + await waitForSuperDocReady(); + + const scrollContainer = screen.getByTestId('superdoc-scroll-container'); + expect(scrollContainer.style.height).toBe('400px'); + }); + + it('uses default documentHeight of 600px when not specified', async () => { + renderComponent(); + + await waitForSuperDocReady(); + + const scrollContainer = screen.getByTestId('superdoc-scroll-container'); + expect(scrollContainer.style.height).toBe('600px'); + }); + }); + + describe('document mode', () => { + it('hides submit button when mode is "download"', async () => { + renderComponent({ + document: { + source: '

Download only

', + mode: 'download', + }, + }); + + await waitForSuperDocReady(); + + expect(screen.queryByRole('button', { name: /submit/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument(); + }); + }); + + describe('custom labels', () => { + it('renders custom submit label', async () => { + renderComponent({ + submit: { label: 'Sign & Complete' }, + }); + + await waitForSuperDocReady(); + + expect(screen.getByRole('button', { name: 'Sign & Complete' })).toBeInTheDocument(); + }); + + it('renders custom download label', async () => { + renderComponent({ + download: { label: 'Save Copy' }, + }); + + await waitForSuperDocReady(); + + expect(screen.getByRole('button', { name: 'Save Copy' })).toBeInTheDocument(); + }); + }); + + describe('field validation', () => { + it('keeps submit disabled when required checkbox is unchecked', async () => { + renderComponent({ + fields: { + signer: [ + { + id: '1', + type: 'checkbox', + label: 'Required checkbox', + validation: { required: true }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).toBeDisabled()); + }); + + it('enables submit when all required fields are filled', async () => { + renderComponent({ + fields: { + signer: [ + { + id: '1', + type: 'checkbox', + label: 'Required', + validation: { required: true }, + }, + { + id: '2', + type: 'checkbox', + label: 'Optional', + validation: { required: false }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const checkboxes = screen.getAllByRole('checkbox'); + // Only check the required one + await userEvent.click(checkboxes[0]); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + }); + + it('treats optional fields as valid when empty', async () => { + const onSubmit = vi.fn(); + + renderComponent({ + onSubmit, + fields: { + signer: [ + { + id: '1', + type: 'checkbox', + label: 'Optional', + validation: { required: false }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + await userEvent.click(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); + }); + + describe('multiple field types', () => { + it('renders signature, checkbox, and text fields together', async () => { + renderComponent({ + fields: { + signer: [ + { + id: 'sig', + type: 'signature', + label: 'Your Signature', + validation: { required: true }, + }, + { + id: 'cb', + type: 'checkbox', + label: 'I agree', + validation: { required: true }, + }, + { + id: 'txt', + type: 'text', + label: 'Your Name', + validation: { required: false }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + // Signature and text both render as text inputs + const inputs = screen.getAllByPlaceholderText('Type your full name'); + expect(inputs).toHaveLength(2); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeInTheDocument(); + }); + + it('includes all field values in submit payload', async () => { + const onSubmit = vi.fn(); + + const { getByRole } = renderComponent({ + onSubmit, + fields: { + signer: [ + { + id: 'sig', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + { + id: 'cb', + type: 'checkbox', + label: 'Accept', + validation: { required: true }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const input = screen.getByPlaceholderText('Type your full name'); + fireEvent.change(input, { target: { value: 'Test User' } }); + + const checkbox = getByRole('checkbox'); + await userEvent.click(checkbox); + + const submitButton = getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + await userEvent.click(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + const payload = onSubmit.mock.calls[0][0]; + expect(payload.signerFields).toEqual([ + { id: 'sig', value: 'Test User' }, + { id: 'cb', value: true }, + ]); + }); + }); + + describe('onFieldsDiscovered callback', () => { + it('invokes onFieldsDiscovered with discovered fields on ready', async () => { + const onFieldsDiscovered = vi.fn(); + + superDocMock.mockGetStructuredContentTags.mockReturnValue([ + { + node: { + attrs: { id: 'f1', label: 'Name' }, + type: { name: 'structuredContentBlock' }, + textContent: 'default', + }, + }, + { node: { attrs: { id: 'f2', label: 'Date' }, type: { name: 'structuredContentBlock' }, textContent: '' } }, + ]); + + renderComponent({ + onFieldsDiscovered, + fields: { + document: [ + { id: 'f1', value: 'John' }, + { id: 'f2', value: '2024-01-01' }, + ], + }, + }); + + await waitForSuperDocReady(); + + await waitFor(() => expect(onFieldsDiscovered).toHaveBeenCalledTimes(1)); + + const discovered = onFieldsDiscovered.mock.calls[0][0]; + expect(discovered).toEqual([ + { id: 'f1', label: 'Name', value: 'John' }, + { id: 'f2', label: 'Date', value: '2024-01-01' }, + ]); + }); + }); + + describe('destroy on unmount', () => { + it('calls destroy on SuperDoc instance when component unmounts', async () => { + const { unmount } = renderComponent(); + + await waitForSuperDocReady(); + + unmount(); + + expect(superDocMock.mockDestroy).toHaveBeenCalled(); + }); + }); + + describe('PDF support', () => { + const pdfConfig = { + pdfLib: {}, + workerSrc: 'mock-worker.js', + setWorker: false, + outputScale: 2, + }; + + const waitForPdfReady = async () => { + await waitFor(() => { + const options = getLastConstructorOptions(); + expect(options).toBeTruthy(); + expect(options.modules?.pdf).toBeTruthy(); + }); + }; + + it('passes pdf config to SuperDoc modules when pdf prop is provided', async () => { + renderComponent({ + pdf: pdfConfig, + document: { + source: 'https://example.com/doc.pdf', + mode: 'full', + }, + }); + + await waitForPdfReady(); + + const options = getLastConstructorOptions(); + expect(options.modules).toEqual({ + comments: false, + pdf: pdfConfig, + }); + }); + + it('converts string source to { url, type: "pdf" } when pdf prop is provided', async () => { + renderComponent({ + pdf: pdfConfig, + document: { + source: 'https://example.com/doc.pdf', + mode: 'full', + }, + }); + + await waitForPdfReady(); + + const options = getLastConstructorOptions(); + expect(options.document).toEqual({ + url: 'https://example.com/doc.pdf', + type: 'pdf', + }); + }); + + it('does not convert document source when pdf prop is not provided', async () => { + renderComponent({ + document: { + source: 'https://example.com/doc.docx', + mode: 'full', + }, + }); + + await waitForSuperDocReady(); + + const options = getLastConstructorOptions(); + expect(options.document).toBe('https://example.com/doc.docx'); + }); + + it('fires onPdfDocumentReady and sets component ready for PDFs', async () => { + const onStateChange = vi.fn(); + + renderComponent({ + pdf: pdfConfig, + onStateChange, + document: { + source: 'https://example.com/doc.pdf', + mode: 'full', + }, + fields: { + signer: [ + { + id: 'cb1', + type: 'checkbox', + label: 'I agree', + validation: { required: true }, + }, + ], + }, + }); + + await waitForPdfReady(); + + // The component should become ready — verify the checkbox is interactive + const checkbox = await screen.findByRole('checkbox'); + expect(checkbox).toBeInTheDocument(); + }); + + it('does not pass pdf module when pdf prop is omitted', async () => { + renderComponent({ + document: { + source: '

Test

', + mode: 'full', + }, + }); + + await waitForSuperDocReady(); + + const options = getLastConstructorOptions(); + expect(options.modules).toEqual({ comments: false }); + }); + + it('records ready audit event via onPdfDocumentReady', async () => { + const ref = createRef(); + + renderComponent( + { + pdf: pdfConfig, + document: { + source: 'https://example.com/doc.pdf', + mode: 'full', + }, + }, + { ref }, + ); + + await waitForPdfReady(); + await waitFor(() => expect(ref.current).toBeTruthy()); + + await waitFor(() => { + const auditTrail = ref.current?.getAuditTrail() ?? []; + const types = auditTrail.map((e) => e.type); + expect(types).toContain('ready'); + }); + }); + + it('enables submit flow for PDF with signer fields', async () => { + const onSubmit = vi.fn(); + + const { getByRole } = renderComponent({ + pdf: pdfConfig, + onSubmit, + document: { + source: 'https://example.com/doc.pdf', + mode: 'full', + }, + fields: { + signer: [ + { + id: 'cb1', + type: 'checkbox', + label: 'I agree', + validation: { required: true }, + }, + ], + }, + }); + + await waitForPdfReady(); + + const checkbox = await screen.findByRole('checkbox'); + await userEvent.click(checkbox); + + const submitButton = getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + await userEvent.click(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + const submitData = onSubmit.mock.calls[0][0]; + expect(submitData.signerFields).toEqual([{ id: 'cb1', value: true }]); + }); + }); + describe('viewOptions configuration', () => { it('passes viewOptions directly to SuperDoc when provided', async () => { renderComponent({ diff --git a/packages/esign/src/index.tsx b/packages/esign/src/index.tsx index 47245f73a..9483646b5 100644 --- a/packages/esign/src/index.tsx +++ b/packages/esign/src/index.tsx @@ -28,6 +28,7 @@ const SuperDocESign = forwardRef { + if (aborted) return; + addAuditEvent({ type: 'ready' }); + setIsReady(true); + }, }); superdocRef.current = instance; @@ -254,7 +261,8 @@ const SuperDocESign = forwardRef { lastConstructorOptions = options; - if (options?.onReady) { + const isPdf = options?.modules?.pdf; + + if (isPdf && options?.onPdfDocumentReady) { + if (typeof queueMicrotask === 'function') { + queueMicrotask(() => options.onPdfDocumentReady()); + } else { + Promise.resolve().then(() => options.onPdfDocumentReady()); + } + } else if (options?.onReady) { if (typeof queueMicrotask === 'function') { queueMicrotask(() => options.onReady()); } else { @@ -61,7 +69,7 @@ const SuperDocMock = vi.fn((options: any = {}) => { return { destroy: mockDestroy, - activeEditor: mockEditor, + activeEditor: isPdf ? null : mockEditor, on: vi.fn(), }; }); diff --git a/packages/esign/src/types.ts b/packages/esign/src/types.ts index 5c744c1b6..5788f2af0 100644 --- a/packages/esign/src/types.ts +++ b/packages/esign/src/types.ts @@ -56,6 +56,14 @@ export interface SubmitConfig { component?: React.ComponentType; } +export interface PdfModuleConfig { + pdfLib: any; + workerSrc?: string; + setWorker?: boolean; + textLayer?: boolean; + outputScale?: number; +} + export interface LayoutMargins { top?: number; bottom?: number; @@ -112,9 +120,10 @@ export interface SuperDocESignProps { onFieldChange?: (field: FieldChange) => void; onFieldsDiscovered?: (fields: FieldInfo[]) => void; + pdf?: PdfModuleConfig; + /** Telemetry configuration for SuperDoc */ telemetry?: { enabled: boolean; metadata?: Record }; - /** License key for SuperDoc */ licenseKey?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51d1337db..569047d05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -723,6 +723,9 @@ importers: '@superdoc-dev/esign': specifier: workspace:* version: link:.. + pdfjs-dist: + specifier: 5.4.624 + version: 5.4.624 react: specifier: 'catalog:' version: 19.2.4