feat: sign document with text

This commit is contained in:
Ephraim Atta-Duncan
2023-12-11 12:03:22 +00:00
parent e4b7747f66
commit 6ad3edb6c8
10 changed files with 112 additions and 21 deletions

View File

@ -1,7 +1,7 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": "explicit"
}, },
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative", "javascript.preferences.importModuleSpecifier": "non-relative",

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';

View File

@ -4,8 +4,10 @@ import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
@ -28,6 +30,19 @@ export type SigningFormProps = {
fields: Field[]; fields: Field[];
}; };
const ZSigningpadSchema = z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null().or(z.string().max(0)),
}),
z.object({
signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().trim().min(1),
}),
]);
export type TSigningpadSchema = z.infer<typeof ZSigningpadSchema>;
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => { export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics(); const analytics = useAnalytics();
@ -40,9 +55,22 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const { const {
register,
handleSubmit, handleSubmit,
setValue,
watch,
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm(); } = useForm<TSigningpadSchema>({
mode: 'onChange',
defaultValues: {
signatureDataUrl: signature || null,
signatureText: '',
},
resolver: zodResolver(ZSigningpadSchema),
});
const signatureDataUrl = watch('signatureDataUrl');
const signatureText = watch('signatureText');
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted)); return sortFieldsByPosition(fields.filter((field) => !field.inserted));
@ -118,15 +146,69 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
<div> <div>
<Label htmlFor="Signature">Signature</Label> <Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}> <Card id="signature" className="mt-4" degrees={-120} gradient>
<CardContent className="p-0"> <CardContent role="button" className="relative cursor-pointer pt-6">
<SignaturePad <div className="flex h-44 items-center justify-center pb-6">
className="h-44 w-full" {!signatureText && signature && (
defaultValue={signature ?? undefined} <SignaturePad
onChange={(value) => { className="h-44 w-full"
setSignature(value); defaultValue={signature ?? undefined}
}} onChange={(value) => {
/> setSignature(value);
}}
/>
)}
{signatureText && (
<p
className={cn(
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
)}
>
{signatureText}
</p>
)}
</div>
<div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-end justify-between px-4 pb-1 pt-2"
onClick={(e) => e.stopPropagation()}
>
<Input
id="signatureText"
className="text-foreground placeholder:text-muted-foreground border-none bg-transparent p-0 text-sm focus-visible:bg-transparent focus-visible:outline-none focus-visible:ring-0"
placeholder="Draw or type name here"
// disabled={isSubmitting || signature !== null}
disabled={isSubmitting}
{...register('signatureText', {
onChange: (e) => {
if (e.target.value !== '') {
setValue('signatureDataUrl', null);
}
setValue('signatureText', e.target.value);
},
onBlur: (e) => {
if (e.target.value === '') {
return setValue('signatureText', '');
}
setSignature(e.target.value.trimStart());
},
})}
/>
{/* <div className="absolute bottom-3 right-4">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
onClick={() => console.log('clear')}
>
Clear Signature
</button>
</div> */}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Document, Field } from '@documenso/prisma/client'; import type { Document, Field } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,

View File

@ -86,7 +86,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value, value,
isBase64: true, isBase64: typeof value === 'string' && value.startsWith('data:image/png;base64,'),
}); });
if (source === 'local' && !providedSignature) { if (source === 'local' && !providedSignature) {

View File

@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
export type SignatureFieldProps = { export type SignatureFieldProps = {

View File

@ -1,7 +1,7 @@
import { APP_BASE_URL } from './app'; import { APP_BASE_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 15; export const DEFAULT_STANDARD_FONT_SIZE = 15;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50; export const DEFAULT_HANDWRITING_FONT_SIZE = 30;
export const MIN_STANDARD_FONT_SIZE = 8; export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20; export const MIN_HANDWRITING_FONT_SIZE = 20;

View File

@ -69,6 +69,8 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
console.log('Fields to insert into PDF: ', fields);
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);
} }

View File

@ -46,8 +46,12 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
await pdf.embedFont(fontCaveat); await pdf.embedFont(fontCaveat);
} }
const CUSTOM_TEXT = field.customText || field.Signature?.typedSignature || '';
const isInsertingImage = const isInsertingImage =
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string'; isSignatureField &&
typeof field.Signature?.signatureImageAsBase64 === 'string' &&
field.Signature?.signatureImageAsBase64.startsWith('data:image/png;base64,');
if (isSignatureField && isInsertingImage) { if (isSignatureField && isInsertingImage) {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? ''); const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
@ -73,13 +77,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
height: imageHeight, height: imageHeight,
}); });
} else { } else {
let textWidth = font.widthOfTextAtSize(field.customText, fontSize); let textWidth = font.widthOfTextAtSize(CUSTOM_TEXT, fontSize);
const textHeight = font.heightAtSize(fontSize); const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1); const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize); fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(field.customText, fontSize); textWidth = font.widthOfTextAtSize(CUSTOM_TEXT, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2; const textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2; let textY = fieldY + (fieldHeight - textHeight) / 2;
@ -87,7 +91,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
// Invert the Y axis since PDFs use a bottom-left coordinate system // Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight; textY = pageHeight - textY - textHeight;
page.drawText(field.customText, { page.drawText(CUSTOM_TEXT, {
x: textX, x: textX,
y: textY, y: textY,
size: fontSize, size: fontSize,

View File

@ -15,12 +15,14 @@ const DPI = 2;
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & { export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void; onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string; containerClassName?: string;
clearSignatureClassName?: string;
}; };
export const SignaturePad = ({ export const SignaturePad = ({
className, className,
containerClassName, containerClassName,
defaultValue, defaultValue,
clearSignatureClassName,
onChange, onChange,
...props ...props
}: SignaturePadProps) => { }: SignaturePadProps) => {
@ -217,7 +219,7 @@ export const SignaturePad = ({
{...props} {...props}
/> />
<div className="absolute bottom-4 right-4"> <div className={cn('absolute bottom-4 right-4', clearSignatureClassName)}>
<button <button
type="button" type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2" className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"