mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 09:41:35 +10:00
feat: sign document with text
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -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",
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user