diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index d4305a04c3..f5de083708 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -401,8 +401,13 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { setDraftSignatureDataUrl(value)} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 7e6cf26b8e..d06faf8526 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -34,7 +34,8 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin const analytics = useAnalytics(); const { data: session } = useSession(); - const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + const { fullName, signature, setFullName, setSignature, signatureType, setSignatureType } = + useRequiredSigningContext(); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); @@ -170,10 +171,14 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin { - setSignature(value); + onChange={(v: string | null, isUploaded: boolean) => { + setSignature(v); + setSignatureType(isUploaded ? 'UPLOAD' : 'DRAW'); }} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 99b9d1dd73..82a45fd0d0 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -107,6 +107,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp email={recipient.email} fullName={user?.email === recipient.email ? user.name : recipient.name} signature={user?.email === recipient.email ? user.signature : undefined} + signatureType={user?.email === recipient.email ? user.signatureType : undefined} >

diff --git a/apps/web/src/app/(signing)/sign/[token]/provider.tsx b/apps/web/src/app/(signing)/sign/[token]/provider.tsx index 454007cb05..597c2f7643 100644 --- a/apps/web/src/app/(signing)/sign/[token]/provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/provider.tsx @@ -2,6 +2,8 @@ import { createContext, useContext, useState } from 'react'; +import { SignatureType } from '@documenso/prisma/client'; + export type SigningContextValue = { fullName: string; setFullName: (_value: string) => void; @@ -9,6 +11,8 @@ export type SigningContextValue = { setEmail: (_value: string) => void; signature: string | null; setSignature: (_value: string | null) => void; + signatureType: SignatureType | null; + setSignatureType: (_value: SignatureType | null) => void; }; const SigningContext = createContext(null); @@ -31,6 +35,7 @@ export interface SigningProviderProps { fullName?: string | null; email?: string | null; signature?: string | null; + signatureType?: SignatureType | null; children: React.ReactNode; } @@ -38,11 +43,15 @@ export const SigningProvider = ({ fullName: initialFullName, email: initialEmail, signature: initialSignature, + signatureType: initialSignatureType, children, }: SigningProviderProps) => { const [fullName, setFullName] = useState(initialFullName || ''); const [email, setEmail] = useState(initialEmail || ''); const [signature, setSignature] = useState(initialSignature || null); + const [signatureType, setSignatureType] = useState( + initialSignatureType || null, + ); return ( {children} diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 220d3084ae..dbc16739ef 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; import type { Recipient } from '@documenso/prisma/client'; +import { SignatureType } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -29,8 +30,11 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const router = useRouter(); const { toast } = useToast(); - const { signature: providedSignature, setSignature: setProvidedSignature } = - useRequiredSigningContext(); + const { + signature: providedSignature, + setSignature: setProvidedSignature, + setSignatureType: setProvidedSignatureType, + } = useRequiredSigningContext(); const [isPending, startTransition] = useTransition(); @@ -47,7 +51,10 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const [showSignatureModal, setShowSignatureModal] = useState(false); - const [localSignature, setLocalSignature] = useState(null); + const [localSignature, setLocalSignature] = useState<{ + value: string | null; + type: SignatureType | null; + } | null>(); const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false); const state = useMemo(() => { @@ -70,13 +77,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const onSign = async (source: 'local' | 'provider' = 'provider') => { try { - if (!providedSignature && !localSignature) { + if (!providedSignature && !localSignature?.value) { setIsLocalSignatureSet(false); setShowSignatureModal(true); return; } - const value = source === 'local' && localSignature ? localSignature : providedSignature ?? ''; + const value = + source === 'local' && localSignature?.value + ? localSignature.value + : providedSignature ?? ''; if (!value) { return; @@ -90,7 +100,8 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { }); if (source === 'local' && !providedSignature) { - setProvidedSignature(localSignature); + setProvidedSignature(localSignature?.value ?? ''); + setProvidedSignatureType(localSignature?.type ?? 'DRAW'); } setLocalSignature(null); @@ -167,8 +178,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { setLocalSignature(value)} + onChange={(value: string | null, isUploaded: boolean) => { + setLocalSignature({ + value, + type: isUploaded ? SignatureType.UPLOAD : SignatureType.DRAW, + }); + }} />

diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 2c278292fd..d2f5520d42 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useState } from 'react'; + import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -21,12 +23,12 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZProfileFormSchema = z.object({ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), - signature: z.string().min(1, 'Signature Pad cannot be empty'), + signature: z.string(), }); export type TProfileFormSchema = z.infer; @@ -40,6 +42,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const router = useRouter(); const { toast } = useToast(); + const [isUploaded, setIsUploaded] = useState(false); const form = useForm({ values: { @@ -55,9 +58,17 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { + if (signature === '') { + form.setError('signature', { + type: 'manual', + message: 'Signature Pad cannot be empty', + }); + return; + } await updateProfile({ name, signature, + signatureType: isUploaded ? 'UPLOAD' : 'DRAW', }); toast({ @@ -85,6 +96,11 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; + const handleSignatureChange = (signature: string | null, isUploaded: boolean) => { + setIsUploaded(isUploaded); + form.setValue('signature', signature ?? ''); + }; + return (
{ ( + render={() => ( Signature @@ -123,8 +139,13 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { className="h-44 w-full" disabled={isSubmitting} containerClassName={cn('rounded-lg border bg-background')} - defaultValue={user.signature ?? undefined} - onChange={(v) => onChange(v ?? '')} + signature={{ + value: user.signature, + type: user.signatureType, + }} + onChange={(v: string | null, isUploaded: boolean) => + handleSignatureChange(v, isUploaded) + } /> diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 7082bcee3e..f77a65054b 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -9,6 +10,7 @@ import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { SignatureType } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -24,7 +26,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-input'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; const SIGN_UP_REDIRECT_PATH = '/documents'; @@ -35,6 +37,7 @@ export const ZSignUpFormSchema = z email: z.string().email().min(1), password: ZPasswordSchema, signature: z.string().min(1, { message: 'We need your signature to sign documents' }), + signatureType: z.nativeEnum(SignatureType), }) .refine( (data) => { @@ -57,6 +60,7 @@ export type SignUpFormProps = { export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); + const [isUploaded, setIsUploaded] = useState(false); const router = useRouter(); const form = useForm({ @@ -65,6 +69,7 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign email: initialEmail ?? '', password: '', signature: '', + signatureType: 'DRAW', }, resolver: zodResolver(ZSignUpFormSchema), }); @@ -75,7 +80,13 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { try { - await signup({ name, email, password, signature }); + await signup({ + name, + email, + password, + signature, + signatureType: isUploaded ? SignatureType.UPLOAD : SignatureType.DRAW, + }); router.push(`/unverified-account`); @@ -121,6 +132,11 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign } }; + const handleSignatureChange = (signature: string | null, isUploaded: boolean) => { + setIsUploaded(isUploaded); + form.setValue('signature', signature ?? ''); + }; + return ( onChange(v ?? '')} + signature={{ + value: form.watch('signature'), + type: isUploaded ? 'UPLOAD' : 'DRAW', + }} + onChange={(v: string | null, isUploaded: boolean) => + handleSignatureChange(v, isUploaded) + } /> diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index 42a9f128c8..131d2eb7ee 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -3,6 +3,7 @@ import { hash } from 'bcrypt'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { prisma } from '@documenso/prisma'; +import type { SignatureType } from '@documenso/prisma/client'; import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client'; import { IS_BILLING_ENABLED } from '../../constants/app'; @@ -13,9 +14,16 @@ export interface CreateUserOptions { email: string; password: string; signature?: string | null; + signatureType: SignatureType; } -export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { +export const createUser = async ({ + name, + email, + password, + signature, + signatureType, +}: CreateUserOptions) => { const hashedPassword = await hash(password, SALT_ROUNDS); const userExists = await prisma.user.findFirst({ @@ -34,6 +42,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse email: email.toLowerCase(), password: hashedPassword, signature, + signatureType, identityProvider: IdentityProvider.DOCUMENSO, }, }); diff --git a/packages/lib/server-only/user/update-profile.ts b/packages/lib/server-only/user/update-profile.ts index a99caff991..d446fbfee6 100644 --- a/packages/lib/server-only/user/update-profile.ts +++ b/packages/lib/server-only/user/update-profile.ts @@ -1,5 +1,6 @@ import { prisma } from '@documenso/prisma'; import { UserSecurityAuditLogType } from '@documenso/prisma/client'; +import type { SignatureType } from '@documenso/prisma/client'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; @@ -8,12 +9,14 @@ export type UpdateProfileOptions = { name: string; signature: string; requestMetadata?: RequestMetadata; + signatureType: SignatureType; }; export const updateProfile = async ({ userId, name, signature, + signatureType, requestMetadata, }: UpdateProfileOptions) => { // Existence check @@ -40,6 +43,7 @@ export const updateProfile = async ({ data: { name, signature, + signatureType, }, }); }); diff --git a/packages/prisma/migrations/20240205103549_add_signature_type/migration.sql b/packages/prisma/migrations/20240205103549_add_signature_type/migration.sql new file mode 100644 index 0000000000..2d98937227 --- /dev/null +++ b/packages/prisma/migrations/20240205103549_add_signature_type/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "SignatureType" AS ENUM ('DRAW', 'UPLOAD'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "signatureType" "SignatureType" NOT NULL DEFAULT 'DRAW'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2887cd6d27..6289ac1c02 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -18,6 +18,11 @@ enum Role { USER } +enum SignatureType { + DRAW + UPLOAD +} + model User { id Int @id @default(autoincrement()) name String? @@ -27,6 +32,7 @@ model User { password String? source String? signature String? + signatureType SignatureType @default(DRAW) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt lastSignedIn DateTime @default(now()) diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 24dd272eeb..44a439961a 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -18,9 +18,9 @@ export const authRouter = router({ }); } - const { name, email, password, signature } = input; + const { name, email, password, signature, signatureType } = input; - const user = await createUser({ name, email, password, signature }); + const user = await createUser({ name, email, password, signature, signatureType }); await sendConfirmationToken({ email: user.email }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index dbe42a25cd..bc854d42ab 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { SignatureType } from '@documenso/prisma/client'; + export const ZCurrentPasswordSchema = z .string() .min(6, { message: 'Must be at least 6 characters in length' }) @@ -21,6 +23,7 @@ export const ZSignUpMutationSchema = z.object({ email: z.string().email(), password: ZPasswordSchema, signature: z.string().min(1, { message: 'A signature is required.' }), + signatureType: z.nativeEnum(SignatureType), }); export type TSignUpMutationSchema = z.infer; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index bceee020a2..55eb4afb26 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -54,12 +54,13 @@ export const profileRouter = router({ .input(ZUpdateProfileMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { name, signature } = input; + const { name, signature, signatureType } = input; return await updateProfile({ userId: ctx.user.id, name, signature, + signatureType, requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 522b135522..a3850d5859 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { SignatureType } from '@documenso/prisma/client'; + import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema'; export const ZFindUserSecurityAuditLogsSchema = z.object({ @@ -14,6 +16,7 @@ export const ZRetrieveUserByIdQuerySchema = z.object({ export const ZUpdateProfileMutationSchema = z.object({ name: z.string().min(1), signature: z.string(), + signatureType: z.nativeEnum(SignatureType), }); export const ZUpdatePasswordMutationSchema = z.object({ diff --git a/packages/ui/primitives/constants.ts b/packages/ui/primitives/constants.ts index 9771eb35a8..00207eb67f 100644 --- a/packages/ui/primitives/constants.ts +++ b/packages/ui/primitives/constants.ts @@ -1,5 +1,5 @@ export const THEMES_TYPE = { DARK: 'dark', LIGHT: 'light', - SYSTEM: 'system' -}; \ No newline at end of file + SYSTEM: 'system', +}; diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx index 5accdca16c..f119b85885 100644 --- a/packages/ui/primitives/document-flow/add-signature.tsx +++ b/packages/ui/primitives/document-flow/add-signature.tsx @@ -287,9 +287,13 @@ export const AddSignatureFormPartial = ({ { + onChange={(value: string | null, _: boolean) => { onFormValueChange(FieldType.SIGNATURE); field.onChange(value); }} diff --git a/packages/ui/primitives/signature-dropzone.tsx b/packages/ui/primitives/signature-dropzone.tsx new file mode 100644 index 0000000000..c4762da965 --- /dev/null +++ b/packages/ui/primitives/signature-dropzone.tsx @@ -0,0 +1,78 @@ +'use client'; + +import type { Variants } from 'framer-motion'; +import { motion } from 'framer-motion'; +import { useDropzone } from 'react-dropzone'; + +import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; + +import { cn } from '../lib/utils'; +import { Card, CardContent } from './card'; + +const DocumentDropzoneContainerVariants: Variants = { + initial: { + scale: 1, + }, + animate: { + scale: 1, + }, + hover: { + transition: { + staggerChildren: 0.05, + }, + }, +}; + +export type SignatureDropzoneProps = { + className?: string; + onDrop?: (_file: File) => void | Promise; + disabled?: boolean; + disabledMessage?: string; +}; + +export const SignatureDropzone = ({ + className, + onDrop, + disabledMessage = 'You cannot upload a signature', + ...props +}: SignatureDropzoneProps) => { + const { getRootProps, getInputProps } = useDropzone({ + accept: { + 'image/png': ['.png'], + }, + maxFiles: 1, + onDrop: ([accceptedFile]) => { + if (accceptedFile && onDrop) { + void onDrop(accceptedFile); + } + }, + maxSize: megabytesToBytes(40), + }); + + return ( + + + + +

+ Add a signature +

+ +

Drag & drop your signature here.

+
+
+
+ ); +}; diff --git a/packages/ui/primitives/signature-pad/drawpad.tsx b/packages/ui/primitives/signature-pad/drawpad.tsx new file mode 100644 index 0000000000..2d8364ad36 --- /dev/null +++ b/packages/ui/primitives/signature-pad/drawpad.tsx @@ -0,0 +1,261 @@ +'use client'; + +import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { Undo2 } from 'lucide-react'; +import type { StrokeOptions } from 'perfect-freehand'; +import { getStroke } from 'perfect-freehand'; + +import { cn } from '../../lib/utils'; +import { getSvgPathFromStroke } from './helper'; +import { Point } from './point'; +import { SignatureType } from '.prisma/client'; + +const DPI = 2; + +export type DrawPadProps = Omit, 'onChange'> & { + onChange?: (_signatureDataUrl: string | null, isUploaded: boolean) => void; + signature: { value: string | null; type: string | null }; + containerClassName?: string; +}; + +export const DrawPad = ({ + className, + containerClassName, + signature, + onChange, + ...props +}: DrawPadProps) => { + const $el = useRef(null); + + const [isPressed, setIsPressed] = useState(false); + const [lines, setLines] = useState([]); + const [currentLine, setCurrentLine] = useState([]); + + const perfectFreehandOptions = useMemo(() => { + const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10; + + return { + size, + thinning: 0.25, + streamline: 0.5, + smoothing: 0.5, + end: { + taper: size * 2, + }, + } satisfies StrokeOptions; + }, []); + + const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => { + if (event.cancelable) { + event.preventDefault(); + } + + setIsPressed(true); + + const point = Point.fromEvent(event, DPI, $el.current); + + setCurrentLine([point]); + }; + + const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => { + if (event.cancelable) { + event.preventDefault(); + } + + if (!isPressed) { + return; + } + + const point = Point.fromEvent(event, DPI, $el.current); + + if (point.distanceTo(currentLine[currentLine.length - 1]) > 5) { + setCurrentLine([...currentLine, point]); + + // Update the canvas here to draw the lines + if ($el.current) { + const ctx = $el.current.getContext('2d'); + + if (ctx) { + ctx.restore(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + lines.forEach((line) => { + const pathData = new Path2D( + getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), + ); + + ctx.fill(pathData); + }); + + const pathData = new Path2D( + getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)), + ); + ctx.fill(pathData); + } + } + } + }; + + const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => { + if (event.cancelable) { + event.preventDefault(); + } + + setIsPressed(false); + + const point = Point.fromEvent(event, DPI, $el.current); + + const newLines = [...lines]; + + if (addLine && currentLine.length > 0) { + newLines.push([...currentLine, point]); + setCurrentLine([]); + } + + setLines(newLines); + + if ($el.current && newLines.length > 0) { + const ctx = $el.current.getContext('2d'); + + if (ctx) { + ctx.restore(); + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + newLines.forEach((line) => { + const pathData = new Path2D( + getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), + ); + ctx.fill(pathData); + }); + + onChange?.($el.current.toDataURL(), false); + + ctx.save(); + } + } + }; + + const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => { + if (event.cancelable) { + event.preventDefault(); + } + + if ('buttons' in event && event.buttons === 1) { + onMouseDown(event); + } + }; + + const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => { + if (event.cancelable) { + event.preventDefault(); + } + + onMouseUp(event, false); + }; + + const onClearClick = () => { + if ($el.current) { + const ctx = $el.current.getContext('2d'); + + ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + } + + onChange?.(null, false); + + setLines([]); + setCurrentLine([]); + }; + + const onUndoClick = () => { + if (lines.length === 0) { + return; + } + + const newLines = [...lines]; + newLines.pop(); // Remove the last line + setLines(newLines); + + // Clear the canvas + if ($el.current) { + const ctx = $el.current.getContext('2d'); + ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + + newLines.forEach((line) => { + const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); + ctx?.fill(pathData); + }); + } + }; + + useEffect(() => { + if ($el.current) { + $el.current.width = $el.current.clientWidth * DPI; + $el.current.height = $el.current.clientHeight * DPI; + } + }, []); + + useEffect(() => { + if ( + $el.current && + typeof signature?.value === 'string' && + signature?.type === SignatureType.DRAW + ) { + const ctx = $el.current.getContext('2d'); + + const { width, height } = $el.current; + + const img = new Image(); + + img.onload = () => { + ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height)); + }; + + img.src = signature.value; + } + }, [signature]); + + return ( +
+ onMouseMove(event)} + onPointerDown={(event) => onMouseDown(event)} + onPointerUp={(event) => onMouseUp(event)} + onPointerLeave={(event) => onMouseLeave(event)} + onPointerEnter={(event) => onMouseEnter(event)} + {...props} + /> + +
+ +
+ + {lines.length > 0 && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index eb9403df48..6e11a44b7b 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -1,261 +1,105 @@ 'use client'; -import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { type HTMLAttributes, useEffect, useState } from 'react'; -import { Undo2 } from 'lucide-react'; -import type { StrokeOptions } from 'perfect-freehand'; -import { getStroke } from 'perfect-freehand'; +import { SignatureType } from '@prisma/client'; +import { TabsContent } from '@radix-ui/react-tabs'; +import { UploadIcon } from 'lucide-react'; -import { cn } from '../../lib/utils'; -import { getSvgPathFromStroke } from './helper'; -import { Point } from './point'; +import { base64 } from '@documenso/lib/universal/base64'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; -const DPI = 2; +import { cn } from '../../lib/utils'; +import { Card, CardContent } from '../card'; +import { SignatureDropzone } from '../signature-dropzone'; +import { Tabs, TabsList, TabsTrigger } from '../tabs'; +import { DrawPad } from './drawpad'; export type SignaturePadProps = Omit, 'onChange'> & { - onChange?: (_signatureDataUrl: string | null) => void; containerClassName?: string; + signature: { value: string | null; type: SignatureType }; disabled?: boolean; + uploadDisable?: boolean; + onChange?: (_signatureDataUrl: string | null, isUploaded: boolean) => void; }; export const SignaturePad = ({ className, containerClassName, - defaultValue, onChange, disabled = false, + uploadDisable = false, + signature, ...props }: SignaturePadProps) => { - const $el = useRef(null); - - const [isPressed, setIsPressed] = useState(false); - const [lines, setLines] = useState([]); - const [currentLine, setCurrentLine] = useState([]); - - const perfectFreehandOptions = useMemo(() => { - const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10; - - return { - size, - thinning: 0.25, - streamline: 0.5, - smoothing: 0.5, - end: { - taper: size * 2, - }, - } satisfies StrokeOptions; - }, []); - - const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => { - if (event.cancelable) { - event.preventDefault(); - } - - setIsPressed(true); - - const point = Point.fromEvent(event, DPI, $el.current); - - setCurrentLine([point]); - }; - - const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => { - if (event.cancelable) { - event.preventDefault(); - } - - if (!isPressed) { - return; - } - - const point = Point.fromEvent(event, DPI, $el.current); - - if (point.distanceTo(currentLine[currentLine.length - 1]) > 5) { - setCurrentLine([...currentLine, point]); - - // Update the canvas here to draw the lines - if ($el.current) { - const ctx = $el.current.getContext('2d'); - - if (ctx) { - ctx.restore(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - - lines.forEach((line) => { - const pathData = new Path2D( - getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), - ); - - ctx.fill(pathData); - }); - - const pathData = new Path2D( - getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)), - ); - ctx.fill(pathData); - } - } - } - }; - - const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => { - if (event.cancelable) { - event.preventDefault(); - } - - setIsPressed(false); - - const point = Point.fromEvent(event, DPI, $el.current); - - const newLines = [...lines]; - - if (addLine && currentLine.length > 0) { - newLines.push([...currentLine, point]); - setCurrentLine([]); - } - - setLines(newLines); - - if ($el.current && newLines.length > 0) { - const ctx = $el.current.getContext('2d'); - - if (ctx) { - ctx.restore(); - - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - - newLines.forEach((line) => { - const pathData = new Path2D( - getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), - ); - ctx.fill(pathData); - }); - - onChange?.($el.current.toDataURL()); - - ctx.save(); - } - } - }; - - const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => { - if (event.cancelable) { - event.preventDefault(); - } - - if ('buttons' in event && event.buttons === 1) { - onMouseDown(event); - } - }; - - const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => { - if (event.cancelable) { - event.preventDefault(); - } - - onMouseUp(event, false); - }; - - const onClearClick = () => { - if ($el.current) { - const ctx = $el.current.getContext('2d'); - - ctx?.clearRect(0, 0, $el.current.width, $el.current.height); - } - - onChange?.(null); - - setLines([]); - setCurrentLine([]); - }; - - const onUndoClick = () => { - if (lines.length === 0) { - return; - } - - const newLines = [...lines]; - newLines.pop(); // Remove the last line - setLines(newLines); + const [uploadedFile, setUploadedFile] = useState( + signature.type === SignatureType.UPLOAD ? signature.value : null, + ); - // Clear the canvas - if ($el.current) { - const ctx = $el.current.getContext('2d'); - ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + const onSignatureDrop = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const base64String = base64.encode(new Uint8Array(arrayBuffer)); - newLines.forEach((line) => { - const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); - ctx?.fill(pathData); - }); + setUploadedFile(`data:image/png;base64,${base64String}`); + onChange?.(`data:image/png;base64,${base64String}`, true); + } catch (error) { + console.error(error); } }; useEffect(() => { - if ($el.current) { - $el.current.width = $el.current.clientWidth * DPI; - $el.current.height = $el.current.clientHeight * DPI; + if (signature.type === SignatureType.DRAW && uploadedFile !== null) { + setUploadedFile(null); } - }, []); - - useEffect(() => { - if ($el.current && typeof defaultValue === 'string') { - const ctx = $el.current.getContext('2d'); - - const { width, height } = $el.current; - - const img = new Image(); - - img.onload = () => { - ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height)); - }; - - img.src = defaultValue; - } - }, [defaultValue]); + }, [signature.type]); return ( -
- onMouseMove(event)} - onPointerDown={(event) => onMouseDown(event)} - onPointerUp={(event) => onMouseUp(event)} - onPointerLeave={(event) => onMouseLeave(event)} - onPointerEnter={(event) => onMouseEnter(event)} - {...props} - /> - -
- -
- - {lines.length > 0 && ( -
- -
- )} +
+ + + + + Draw + + + + Upload + + + + + + + {uploadedFile ? ( + + + + + + + ) : ( + + )} + +
); };