Compare commits

...

16 Commits

Author SHA1 Message Date
536adf6e0a fix: reduce text size? 2024-02-15 10:05:36 +00:00
be3ab09738 fix: set fixed with for signature pad and input 2024-02-05 11:18:03 +00:00
1c4a5449bb feat: use dancing script font locally 2024-01-28 18:19:42 +00:00
144bd4782b chore: remove console.logs 2024-01-28 18:05:17 +00:00
857e35c10a Update apps/web/src/app/(signing)/sign/[token]/form.tsx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-01-28 18:03:10 +00:00
e9d6c24137 chore: remove console.log 2024-01-16 23:16:37 +00:00
dd5f39205a fix: position of undo signature button 2024-01-16 23:14:34 +00:00
f6ce7be61f fix: disable input for only drawn signature 2024-01-16 23:10:08 +00:00
9979d32a56 Merge branch 'main' into feat/accept-text-signature 2024-01-16 15:26:39 +00:00
daa541d570 fix: disable input when there is a signature available 2024-01-16 02:52:58 +00:00
1d91a9e813 fix: canvas for drawing signature and clear signature position 2024-01-16 02:40:43 +00:00
075e15d428 feat: replace caveat font with dancing script 2024-01-16 01:54:28 +00:00
27e5ef0a51 Revert "INCOMPLETE: refactor signature pad and input into a single component"
This reverts commit e17e4566cd.
2024-01-16 01:17:11 +00:00
0b8e84b6b7 refactor: singature pad & provider stuff
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-01-12 17:13:00 +05:30
e17e4566cd INCOMPLETE: refactor signature pad and input into a single component 2024-01-12 11:04:22 +00:00
6ad3edb6c8 feat: sign document with text 2023-12-11 12:03:22 +00:00
11 changed files with 176 additions and 31 deletions

View File

@ -1,7 +1,7 @@
'use client';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { useMemo, useState } from 'react';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
@ -355,6 +355,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Input
id="signatureText"
@ -392,10 +393,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</DialogHeader>
<DialogDescription>
By signing you signal your support of Documenso's mission in a <br></br>
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
<br></br>You also unlock the option to purchase the early supporter plan including
everything we build this year for fixed price.
By signing you signal your support of Documenso's mission in a <br />
<strong>non-legally binding, but heartfelt way</strong>. <br />
<br />
You also unlock the option to purchase the early supporter plan including everything we
build this year for fixed price.
</DialogDescription>
<SignaturePad

View File

@ -17,6 +17,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
const FONT_DANCING_SCRIPT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/dancing-script.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
@ -40,6 +44,7 @@ const config = {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_DANCING_SCRIPT_URI: `data:font/ttf;base64,${FONT_DANCING_SCRIPT_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

View File

@ -4,10 +4,18 @@ import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -28,7 +36,27 @@ export type SigningFormProps = {
fields: Field[];
};
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
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: _document, recipient, fields }: SigningFormProps) => {
const fontVariable = '--font-signature';
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
fontVariable,
);
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@ -41,9 +69,24 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
trpc.recipient.completeDocumentWithToken.useMutation();
const {
register,
handleSubmit,
setValue,
watch,
formState: { isSubmitting },
} = useForm();
} = useForm<TSigningpadSchema>({
mode: 'onChange',
defaultValues: {
signatureDataUrl: signature || null,
signatureText: '',
},
resolver: zodResolver(ZSigningpadSchema),
});
const { height, width } = useFieldPageCoords(fields.find((field) => field.type === 'SIGNATURE')!);
const signatureDataUrl = watch('signatureDataUrl');
const signatureText = watch('signatureText');
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
@ -65,18 +108,30 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
documentId: _document.id,
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
documentId: _document.id,
timestamp: new Date().toISOString(),
});
router.push(`/sign/${recipient.token}/complete`);
};
const scalingFactor = useElementScaleSize(
{
height,
width,
},
signatureText || '',
maxFontSize,
fontVariableValue,
);
const fontSize = maxFontSize * scalingFactor;
return (
<form
className={cn(
@ -128,15 +183,79 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
<Card id="signature" className="mt-4" degrees={-120} gradient>
<CardContent role="button" className="relative cursor-pointer pt-6">
<div className="flex h-44 max-w-[18rem] items-center justify-center pb-6">
{!signatureText && (
<SignaturePad
className="h-44"
defaultValue={signature ?? undefined}
clearSignatureClassName="absolute -bottom-6 -right-2 z-10 cursor-pointer"
undoSignatureClassName="absolute -top-32 -left-4 z-10 cursor-pointer"
onChange={(value) => {
setSignature(value);
}}
/>
)}
{signatureText && (
<p
style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`,
}}
className={cn(
'text-foreground font-signature max-w-[18rem] text-4xl font-semibold',
)}
>
{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()}
onKeyDown={(e) => e.stopPropagation()}
>
<Input
id="signatureText"
className="text-foreground placeholder:text-muted-foreground max-w-[15rem] border-0 border-none bg-transparent p-0 text-sm focus-visible:ring-transparent"
placeholder="Draw or type your name here"
disabled={isSubmitting || signature?.startsWith('data:')}
{...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());
},
})}
/>
{signatureText && (
<div className="absolute bottom-3 right-4 z-10 cursor-pointer">
<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={() => {
setValue('signatureText', '');
setValue('signatureDataUrl', null);
}}
>
Clear Signature
</button>
</div>
)}
</div>
</CardContent>
</Card>
</div>
@ -157,7 +276,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
document={_document}
fields={fields}
fieldsValidated={fieldsValidated}
/>

View File

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

Binary file not shown.

View File

@ -1,7 +1,7 @@
import { APP_BASE_URL } from './app';
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_HANDWRITING_FONT_SIZE = 20;

View File

@ -12,7 +12,7 @@ import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-fiel
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
const fontDancingScript = await fetch(process.env.FONT_DANCING_SCRIPT_URI).then(async (res) =>
res.arrayBuffer(),
);
@ -40,14 +40,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
const font = await pdf.embedFont(isSignatureField ? fontDancingScript : StandardFonts.Helvetica, {
subset: true,
features: {
liga: false,
},
});
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
await pdf.embedFont(fontDancingScript, {
subset: true,
features: {
liga: false,
},
});
}
const CUSTOM_TEXT = field.customText || field.Signature?.typedSignature || '';
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) {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
@ -73,13 +87,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
height: imageHeight,
});
} else {
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
let textWidth = font.widthOfTextAtSize(CUSTOM_TEXT, fontSize);
const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
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;
let textY = fieldY + (fieldHeight - textHeight) / 2;
@ -87,7 +101,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
page.drawText(field.customText, {
page.drawText(CUSTOM_TEXT, {
x: textX,
y: textY,
size: fontSize,

View File

@ -238,7 +238,6 @@ export const documentRouter = router({
userId: ctx.user.id,
});
} catch (err) {
console.log(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We are unable to duplicate this document. Please try again later.',

View File

@ -64,6 +64,7 @@ declare namespace NodeJS {
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
FONT_CAVEAT_URI: string;
FONT_DANCING_SCRIPT_URI: string;
POSTGRES_URL?: string;
DATABASE_URL?: string;

View File

@ -16,12 +16,16 @@ const DPI = 2;
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string;
clearSignatureClassName?: string;
undoSignatureClassName?: string;
};
export const SignaturePad = ({
className,
containerClassName,
defaultValue,
clearSignatureClassName,
undoSignatureClassName,
onChange,
...props
}: SignaturePadProps) => {
@ -227,7 +231,7 @@ export const SignaturePad = ({
{...props}
/>
<div className="absolute bottom-4 right-4 flex gap-2">
<div className={cn('absolute bottom-4 right-4', clearSignatureClassName)}>
<button
type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
@ -238,7 +242,7 @@ export const SignaturePad = ({
</div>
{lines.length > 0 && (
<div className="absolute bottom-4 left-4 flex gap-2">
<div className={cn('absolute bottom-4 left-4 flex gap-2', undoSignatureClassName)}>
<button
type="button"
title="undo"

View File

@ -86,6 +86,7 @@
"VERCEL_URL",
"DEPLOYMENT_TARGET",
"FONT_CAVEAT_URI",
"FONT_DANCING_SCRIPT_URI",
"POSTGRES_URL",
"DATABASE_URL",
"POSTGRES_PRISMA_URL",