diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index c88813e1f..aa780385c 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type DocumentData, type Field, FieldType } from '@prisma/client'; import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client'; +import { type DocumentData, type Field, FieldType } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { DateTime } from 'luxon'; import { useSearchParams } from 'react-router'; @@ -25,12 +25,11 @@ import type { } from '@documenso/trpc/server/field-router/schema'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; @@ -69,16 +68,8 @@ export const EmbedDirectTemplateClientPage = ({ const [searchParams] = useSearchParams(); - const { - fullName, - email, - signature, - signatureValid, - setFullName, - setEmail, - setSignature, - setSignatureValid, - } = useRequiredDocumentSigningContext(); + const { fullName, email, signature, setFullName, setEmail, setSignature } = + useRequiredDocumentSigningContext(); const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); @@ -194,10 +185,6 @@ export const EmbedDirectTemplateClientPage = ({ const onCompleteClick = async () => { try { - if (hasSignatureField && !signatureValid) { - return; - } - const valid = validateFieldsInserted(pendingFields); if (!valid) { @@ -419,34 +406,16 @@ export const EmbedDirectTemplateClientPage = ({ Signature - - - { - setSignature(value); - }} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - allowTypedSignature={Boolean( - metadata && - 'typedSignatureEnabled' in metadata && - metadata.typedSignatureEnabled, - )} - /> - - - - {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} + setSignature(v ?? '')} + typedSignatureEnabled={metadata?.typedSignatureEnabled} + uploadSignatureEnabled={metadata?.uploadSignatureEnabled} + drawSignatureEnabled={metadata?.drawSignatureEnabled} + /> )} diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx index 99c8e4600..0cb53f17c 100644 --- a/apps/remix/app/components/embed/embed-document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -54,6 +54,8 @@ export const EmbedDocumentFields = ({ onSignField={onSignField} onUnsignField={onUnsignField} typedSignatureEnabled={metadata?.typedSignatureEnabled} + uploadSignatureEnabled={metadata?.uploadSignatureEnabled} + drawSignatureEnabled={metadata?.drawSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index 5361ded5e..79d87e6aa 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -21,13 +21,12 @@ import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; @@ -70,15 +69,8 @@ export const EmbedSignDocumentClientPage = ({ const { _ } = useLingui(); const { toast } = useToast(); - const { - fullName, - email, - signature, - signatureValid, - setFullName, - setSignature, - setSignatureValid, - } = useRequiredDocumentSigningContext(); + const { fullName, email, signature, setFullName, setSignature } = + useRequiredDocumentSigningContext(); const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); @@ -129,10 +121,6 @@ export const EmbedSignDocumentClientPage = ({ const onCompleteClick = async () => { try { - if (hasSignatureField && !signatureValid) { - return; - } - const valid = validateFieldsInserted(fieldsRequiringValidation); if (!valid) { @@ -432,34 +420,16 @@ export const EmbedSignDocumentClientPage = ({ Signature - - - { - setSignature(value); - }} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - allowTypedSignature={Boolean( - metadata && - 'typedSignatureEnabled' in metadata && - metadata.typedSignatureEnabled, - )} - /> - - - - {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} + setSignature(v ?? '')} + typedSignatureEnabled={metadata?.typedSignatureEnabled} + uploadSignatureEnabled={metadata?.uploadSignatureEnabled} + drawSignatureEnabled={metadata?.drawSignatureEnabled} + /> )} @@ -477,9 +447,7 @@ export const EmbedSignDocumentClientPage = ({ ) : ( diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index f2abb9fcc..b26f56742 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -30,7 +30,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 { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton'; @@ -353,16 +353,15 @@ export const SignUpForm = ({ ( + render={({ field: { onChange, value } }) => ( Sign Here - onChange(v ?? '')} /> diff --git a/apps/remix/app/components/forms/team-branding-preferences-form.tsx b/apps/remix/app/components/forms/team-branding-preferences-form.tsx index f33345d3b..5cc519960 100644 --- a/apps/remix/app/components/forms/team-branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/team-branding-preferences-form.tsx @@ -308,7 +308,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
diff --git a/apps/remix/app/components/forms/team-document-preferences-form.tsx b/apps/remix/app/components/forms/team-document-preferences-form.tsx index 98701b36b..2b4846116 100644 --- a/apps/remix/app/components/forms/team-document-preferences-form.tsx +++ b/apps/remix/app/components/forms/team-document-preferences-form.tsx @@ -8,12 +8,15 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document'; import { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES, isValidLanguageCode, } from '@documenso/lib/constants/i18n'; +import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { Alert } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,7 +26,9 @@ import { FormField, FormItem, FormLabel, + FormMessage, } from '@documenso/ui/primitives/form/form'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; import { Select, SelectContent, @@ -38,8 +43,10 @@ const ZTeamDocumentPreferencesFormSchema = z.object({ documentVisibility: z.nativeEnum(DocumentVisibility), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES), includeSenderDetails: z.boolean(), - typedSignatureEnabled: z.boolean(), includeSigningCertificate: z.boolean(), + signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, { + message: msg`At least one signature type must be enabled`.id, + }), }); type TTeamDocumentPreferencesFormSchema = z.infer; @@ -69,8 +76,8 @@ export const TeamDocumentPreferencesForm = ({ ? settings?.documentLanguage : 'en', includeSenderDetails: settings?.includeSenderDetails ?? false, - typedSignatureEnabled: settings?.typedSignatureEnabled ?? true, includeSigningCertificate: settings?.includeSigningCertificate ?? true, + signatureTypes: extractTeamSignatureSettings(settings), }, resolver: zodResolver(ZTeamDocumentPreferencesFormSchema), }); @@ -84,7 +91,7 @@ export const TeamDocumentPreferencesForm = ({ documentLanguage, includeSenderDetails, includeSigningCertificate, - typedSignatureEnabled, + signatureTypes, } = data; await updateTeamDocumentPreferences({ @@ -93,8 +100,10 @@ export const TeamDocumentPreferencesForm = ({ documentVisibility, documentLanguage, includeSenderDetails, - typedSignatureEnabled, includeSigningCertificate, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), }, }); @@ -190,6 +199,44 @@ export const TeamDocumentPreferencesForm = ({ )} /> + ( + + + Default Signature Settings + + + + + ({ + label: _(option.label), + value: option.value, + }))} + selectedValues={field.value} + onChange={field.onChange} + className="bg-background w-full" + enableSearch={false} + emptySelectionPlaceholder="Select signature types" + testId="signature-types-combobox" + /> + + + {form.formState.errors.signatureTypes ? ( + + ) : ( + + + Controls which signatures are allowed to be used when signing a document. + + + )} + + )} + /> + - ( - - - Enable Typed Signature - - -
- - - -
- - - - Controls whether the recipients can sign the documents using a typed signature. - Enable or disable the typed signature globally. - - -
- )} - /> - diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 26e542f43..943932c27 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -24,7 +24,6 @@ import type { } from '@documenso/trpc/server/field-router/schema'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainerContent, DocumentFlowFormContainerFooter, @@ -35,7 +34,7 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useStep } from '@documenso/ui/primitives/stepper'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; @@ -73,8 +72,7 @@ export const DirectTemplateSigningForm = ({ template, onSubmit, }: DirectTemplateSigningFormProps) => { - const { fullName, signature, signatureValid, setFullName, setSignature } = - useRequiredDocumentSigningContext(); + const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); const [localFields, setLocalFields] = useState(directRecipientFields); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); @@ -135,8 +133,6 @@ export const DirectTemplateSigningForm = ({ ); }; - const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); - const uninsertedFields = useMemo(() => { return sortFieldsByPosition(localFields.filter((field) => !field.inserted)); }, [localFields]); @@ -149,10 +145,6 @@ export const DirectTemplateSigningForm = ({ const handleSubmit = async () => { setValidateUninsertedFields(true); - if (hasSignatureField && !signatureValid) { - return; - } - const isFieldsValid = validateFieldsInserted(localFields); if (!isFieldsValid) { @@ -240,6 +232,8 @@ export const DirectTemplateSigningForm = ({ onSignField={onSignField} onUnsignField={onUnsignField} typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} + uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled} + drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( @@ -384,19 +378,15 @@ export const DirectTemplateSigningForm = ({ Signature - - - { - setSignature(value); - }} - allowTypedSignature={template.templateMeta?.typedSignatureEnabled} - /> - - + setSignature(value)} + typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} + uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled} + drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled} + /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index cb1d31982..2eee9a043 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -20,11 +20,10 @@ import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { @@ -71,8 +70,7 @@ export const DocumentSigningForm = ({ const assistantSignersId = useId(); - const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = - useRequiredDocumentSigningContext(); + const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); @@ -120,10 +118,6 @@ export const DocumentSigningForm = ({ const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); - if (hasSignatureField && !signatureValid) { - return; - } - if (!isFieldsValid) { return; } @@ -423,32 +417,15 @@ export const DocumentSigningForm = ({ Signature - - - { - setSignatureValid(isValid); - }} - onChange={(value) => { - if (signatureValid) { - setSignature(value); - } - }} - allowTypedSignature={document.documentMeta?.typedSignatureEnabled} - /> - - - - {!signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} + setSignature(v ?? '')} + typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} + uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled} + drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled} + /> )} diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index 2b57ec598..628e66dca 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -177,6 +177,8 @@ export const DocumentSigningPageView = ({ key={field.id} field={field} typedSignatureEnabled={documentMeta?.typedSignatureEnabled} + uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled} + drawSignatureEnabled={documentMeta?.drawSignatureEnabled} /> )) .with(FieldType.INITIALS, () => ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx index ca231949d..9d704f591 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx @@ -1,4 +1,6 @@ -import { createContext, useContext, useEffect, useState } from 'react'; +import { createContext, useContext, useState } from 'react'; + +import { isBase64Image } from '@documenso/lib/constants/signatures'; export type DocumentSigningContextValue = { fullName: string; @@ -7,8 +9,6 @@ export type DocumentSigningContextValue = { setEmail: (_value: string) => void; signature: string | null; setSignature: (_value: string | null) => void; - signatureValid: boolean; - setSignatureValid: (_valid: boolean) => void; }; const DocumentSigningContext = createContext(null); @@ -31,6 +31,9 @@ export interface DocumentSigningProviderProps { fullName?: string | null; email?: string | null; signature?: string | null; + typedSignatureEnabled?: boolean; + uploadSignatureEnabled?: boolean; + drawSignatureEnabled?: boolean; children: React.ReactNode; } @@ -38,18 +41,31 @@ export const DocumentSigningProvider = ({ fullName: initialFullName, email: initialEmail, signature: initialSignature, + typedSignatureEnabled = true, + uploadSignatureEnabled = true, + drawSignatureEnabled = true, children, }: DocumentSigningProviderProps) => { const [fullName, setFullName] = useState(initialFullName || ''); const [email, setEmail] = useState(initialEmail || ''); - const [signature, setSignature] = useState(initialSignature || null); - const [signatureValid, setSignatureValid] = useState(true); - useEffect(() => { - if (initialSignature) { - setSignature(initialSignature); - } - }, [initialSignature]); + // Ensure the user signature doesn't show up if it's not allowed. + const [signature, setSignature] = useState( + (() => { + const sig = initialSignature || ''; + const isBase64 = isBase64Image(sig); + + if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) { + return sig; + } + + if (!isBase64 && typedSignatureEnabled) { + return sig; + } + + return null; + })(), + ); return ( {children} diff --git a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index 1b1f92dbd..381658ab3 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -17,7 +17,6 @@ import type { } from '@documenso/trpc/server/field-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; -import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -29,11 +28,14 @@ import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; + export type DocumentSigningSignatureFieldProps = { field: FieldWithSignature; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; typedSignatureEnabled?: boolean; + uploadSignatureEnabled?: boolean; + drawSignatureEnabled?: boolean; }; export const DocumentSigningSignatureField = ({ @@ -41,6 +43,8 @@ export const DocumentSigningSignatureField = ({ onSignField, onUnsignField, typedSignatureEnabled, + uploadSignatureEnabled, + drawSignatureEnabled, }: DocumentSigningSignatureFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -52,12 +56,8 @@ export const DocumentSigningSignatureField = ({ const containerRef = useRef(null); const [fontSize, setFontSize] = useState(2); - const { - signature: providedSignature, - setSignature: setProvidedSignature, - signatureValid, - setSignatureValid, - } = useRequiredDocumentSigningContext(); + const { signature: providedSignature, setSignature: setProvidedSignature } = + useRequiredDocumentSigningContext(); const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); @@ -89,7 +89,7 @@ export const DocumentSigningSignatureField = ({ }, [field.inserted, signature?.signatureImageAsBase64]); const onPreSign = () => { - if (!providedSignature || !signatureValid) { + if (!providedSignature) { setShowSignatureModal(true); return false; } @@ -102,6 +102,7 @@ export const DocumentSigningSignatureField = ({ const onDialogSignClick = () => { setShowSignatureModal(false); setProvidedSignature(localSignature); + if (!localSignature) { return; } @@ -116,14 +117,14 @@ export const DocumentSigningSignatureField = ({ try { const value = signature || providedSignature; - if (!value || (signature && !signatureValid)) { + if (!value) { setShowSignatureModal(true); return; } const isTypedSignature = !value.startsWith('data:image'); - if (isTypedSignature && !typedSignatureEnabled) { + if (isTypedSignature && typedSignatureEnabled === false) { toast({ title: _(msg`Error`), description: _(msg`Typed signatures are not allowed. Please draw your signature.`), @@ -275,29 +276,14 @@ export const DocumentSigningSignatureField = ({ -
- - -
- setLocalSignature(value)} - allowTypedSignature={typedSignatureEnabled} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - /> -
- - {!signatureValid && ( -
- Signature is too small. Please provide a more complete signature. -
- )} -
+ setLocalSignature(value)} + typedSignatureEnabled={typedSignatureEnabled} + uploadSignatureEnabled={uploadSignatureEnabled} + drawSignatureEnabled={drawSignatureEnabled} + /> @@ -317,7 +303,7 @@ export const DocumentSigningSignatureField = ({ + + + + + + + + ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad-draw.tsx b/packages/ui/primitives/signature-pad/signature-pad-draw.tsx new file mode 100644 index 000000000..bb66c8aeb --- /dev/null +++ b/packages/ui/primitives/signature-pad/signature-pad-draw.tsx @@ -0,0 +1,327 @@ +import type { MouseEvent, PointerEvent, RefObject, TouchEvent } from 'react'; +import { useMemo, useRef, useState } from 'react'; + +import { Trans } from '@lingui/react/macro'; +import { Undo2 } from 'lucide-react'; +import type { StrokeOptions } from 'perfect-freehand'; +import { getStroke } from 'perfect-freehand'; + +import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; +import { + SIGNATURE_CANVAS_DPI, + SIGNATURE_MIN_COVERAGE_THRESHOLD, +} from '@documenso/lib/constants/signatures'; + +import { cn } from '../../lib/utils'; +import { getSvgPathFromStroke } from './helper'; +import { Point } from './point'; +import { SignaturePadColorPicker } from './signature-pad-color-picker'; + +const checkSignatureValidity = (element: RefObject) => { + if (!element.current) { + return false; + } + + const ctx = element.current.getContext('2d'); + + if (!ctx) { + return false; + } + + const imageData = ctx.getImageData(0, 0, element.current.width, element.current.height); + const data = imageData.data; + let filledPixels = 0; + const totalPixels = data.length / 4; + + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] > 0) filledPixels++; + } + + const filledPercentage = filledPixels / totalPixels; + const isValid = filledPercentage > SIGNATURE_MIN_COVERAGE_THRESHOLD; + + return isValid; +}; + +export type SignaturePadDrawProps = { + className?: string; + value: string; + onChange: (_signatureDataUrl: string) => void; +}; + +export const SignaturePadDraw = ({ + className, + value, + onChange, + ...props +}: SignaturePadDrawProps) => { + const $el = useRef(null); + + const $imageData = useRef(null); + const $fileInput = useRef(null); + + const [isPressed, setIsPressed] = useState(false); + const [lines, setLines] = useState([]); + const [currentLine, setCurrentLine] = useState([]); + const [isSignatureValid, setIsSignatureValid] = useState(null); + + const [selectedColor, setSelectedColor] = useState('black'); + + 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, SIGNATURE_CANVAS_DPI, $el.current); + + setCurrentLine([point]); + }; + + const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => { + if (event.cancelable) { + event.preventDefault(); + } + + if (!isPressed) { + return; + } + + const point = Point.fromEvent(event, SIGNATURE_CANVAS_DPI, $el.current); + const lastPoint = currentLine[currentLine.length - 1]; + + if (lastPoint && point.distanceTo(lastPoint) > 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'; + ctx.fillStyle = selectedColor; + + 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, SIGNATURE_CANVAS_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'; + ctx.fillStyle = selectedColor; + + newLines.forEach((line) => { + const pathData = new Path2D( + getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), + ); + ctx.fill(pathData); + }); + + const isValidSignature = checkSignatureValidity($el); + + setIsSignatureValid(isValidSignature); + + if (isValidSignature) { + 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(); + } + + if (isPressed) { + onMouseUp(event, true); + } else { + onMouseUp(event, false); + } + }; + + const onClearClick = () => { + if ($el.current) { + const ctx = $el.current.getContext('2d'); + + ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + $imageData.current = null; + } + + if ($fileInput.current) { + $fileInput.current.value = ''; + } + + onChange(''); + + setLines([]); + setCurrentLine([]); + setIsPressed(false); + }; + + const onUndoClick = () => { + if (lines.length === 0 || !$el.current) { + return; + } + + const newLines = lines.slice(0, -1); + setLines(newLines); + + // Clear and redraw the canvas + const ctx = $el.current.getContext('2d'); + const { width, height } = $el.current; + ctx?.clearRect(0, 0, width, height); + + if ($imageData.current) { + ctx?.putImageData($imageData.current, 0, 0); + } + + newLines.forEach((line) => { + const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); + ctx?.fill(pathData); + }); + + onChange?.($el.current.toDataURL()); + }; + + unsafe_useEffectOnce(() => { + if ($el.current) { + $el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI; + $el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI; + } + + if ($el.current && value) { + 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)); + + const defaultImageData = ctx?.getImageData(0, 0, width, height) || null; + + $imageData.current = defaultImageData; + }; + + img.src = value; + } + }); + + return ( +
+ onMouseMove(event)} + onPointerDown={(event) => onMouseDown(event)} + onPointerUp={(event) => onMouseUp(event)} + onPointerLeave={(event) => onMouseLeave(event)} + onPointerEnter={(event) => onMouseEnter(event)} + {...props} + /> + + + +
+ +
+ + {isSignatureValid === false && ( +
+ + Signature is too small + +
+ )} + + {isSignatureValid && lines.length > 0 && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad-type.tsx b/packages/ui/primitives/signature-pad/signature-pad-type.tsx new file mode 100644 index 000000000..4685bc6ad --- /dev/null +++ b/packages/ui/primitives/signature-pad/signature-pad-type.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +import { cn } from '../../lib/utils'; + +export type SignaturePadTypeProps = { + className?: string; + value?: string; + onChange: (_value: string) => void; +}; + +export const SignaturePadType = ({ className, value, onChange }: SignaturePadTypeProps) => { + // Colors don't actually work for text. + const [selectedColor, setSelectedColor] = useState('black'); + + return ( +
+ onChange(event.target.value.trimStart())} + /> + + {/* */} +
+ ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad-upload.tsx b/packages/ui/primitives/signature-pad/signature-pad-upload.tsx new file mode 100644 index 000000000..726363c02 --- /dev/null +++ b/packages/ui/primitives/signature-pad/signature-pad-upload.tsx @@ -0,0 +1,166 @@ +import { useRef } from 'react'; + +import { Trans } from '@lingui/react/macro'; +import { motion } from 'framer-motion'; +import { UploadCloudIcon } from 'lucide-react'; + +import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; +import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures'; + +import { cn } from '../../lib/utils'; + +const loadImage = async (file: File | undefined): Promise => { + if (!file) { + throw new Error('No file selected'); + } + + if (!file.type.startsWith('image/')) { + throw new Error('Invalid file type'); + } + + if (file.size > 5 * 1024 * 1024) { + throw new Error('Image size should be less than 5MB'); + } + + return new Promise((resolve, reject) => { + const img = new Image(); + const objectUrl = URL.createObjectURL(file); + + img.onload = () => { + URL.revokeObjectURL(objectUrl); + resolve(img); + }; + + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error('Failed to load image')); + }; + + img.src = objectUrl; + }); +}; + +const loadImageOntoCanvas = ( + image: HTMLImageElement, + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, +): ImageData => { + const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height); + + const x = (canvas.width - image.width * scale) / 2; + const y = (canvas.height - image.height * scale) / 2; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + ctx.drawImage(image, x, y, image.width * scale, image.height * scale); + + ctx.restore(); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + return imageData; +}; + +export type SignaturePadUploadProps = { + className?: string; + value: string; + onChange: (_signatureDataUrl: string) => void; +}; + +export const SignaturePadUpload = ({ + className, + value, + onChange, + ...props +}: SignaturePadUploadProps) => { + const $el = useRef(null); + const $imageData = useRef(null); + const $fileInput = useRef(null); + + const handleImageUpload = async (event: React.ChangeEvent) => { + try { + const img = await loadImage(event.target.files?.[0]); + + if (!$el.current) return; + + const ctx = $el.current.getContext('2d'); + if (!ctx) return; + + $imageData.current = loadImageOntoCanvas(img, $el.current, ctx); + onChange?.($el.current.toDataURL()); + } catch (error) { + console.error(error); + } + }; + + unsafe_useEffectOnce(() => { + // Todo: Not really sure if this is required for uploaded images. + if ($el.current) { + $el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI; + $el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI; + } + + if ($el.current && value) { + 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)); + + const defaultImageData = ctx?.getImageData(0, 0, width, height) || null; + + $imageData.current = defaultImageData; + }; + + img.src = value; + } + }); + + return ( +
+ + + + + $fileInput.current?.click()} + > + {!value && ( + +
+
+ + + Upload Signature + +
+
+
+ )} +
+
+ ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 91fd1ca0e..20fbb1aad 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -1,591 +1,199 @@ -import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import type { HTMLAttributes } from 'react'; +import { useState } from 'react'; -import { Trans } from '@lingui/react/macro'; -import { Undo2, Upload } from 'lucide-react'; -import type { StrokeOptions } from 'perfect-freehand'; -import { getStroke } from 'perfect-freehand'; +import { KeyboardIcon, UploadCloudIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; -import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; -import { Input } from '@documenso/ui/primitives/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; +import { DocumentSignatureType } from '@documenso/lib/constants/document'; +import { isBase64Image } from '@documenso/lib/constants/signatures'; +import { SignatureIcon } from '../../icons/signature'; import { cn } from '../../lib/utils'; -import { getSvgPathFromStroke } from './helper'; -import { Point } from './point'; +import { SignaturePadDraw } from './signature-pad-draw'; +import { SignaturePadType } from './signature-pad-type'; +import { SignaturePadUpload } from './signature-pad-upload'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs'; -const DPI = 2; - -const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,'); - -const loadImage = async (file: File | undefined): Promise => { - if (!file) { - throw new Error('No file selected'); - } - - if (!file.type.startsWith('image/')) { - throw new Error('Invalid file type'); - } - - if (file.size > 5 * 1024 * 1024) { - throw new Error('Image size should be less than 5MB'); - } - - return new Promise((resolve, reject) => { - const img = new Image(); - const objectUrl = URL.createObjectURL(file); - - img.onload = () => { - URL.revokeObjectURL(objectUrl); - resolve(img); - }; - - img.onerror = () => { - URL.revokeObjectURL(objectUrl); - reject(new Error('Failed to load image')); - }; - - img.src = objectUrl; - }); -}; - -const loadImageOntoCanvas = ( - image: HTMLImageElement, - canvas: HTMLCanvasElement, - ctx: CanvasRenderingContext2D, -): ImageData => { - const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height); - - const x = (canvas.width - image.width * scale) / 2; - const y = (canvas.height - image.height * scale) / 2; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - ctx.save(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - - ctx.drawImage(image, x, y, image.width * scale, image.height * scale); - - ctx.restore(); - - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - - return imageData; +export type SignaturePadValue = { + type: DocumentSignatureType; + value: string; }; export type SignaturePadProps = Omit, 'onChange'> & { - onChange?: (_signatureDataUrl: string | null) => void; - containerClassName?: string; + value?: string; + onChange?: (_value: SignaturePadValue) => void; + disabled?: boolean; - allowTypedSignature?: boolean; - defaultValue?: string; + + typedSignatureEnabled?: boolean; + uploadSignatureEnabled?: boolean; + drawSignatureEnabled?: boolean; + onValidityChange?: (isValid: boolean) => void; - minCoverageThreshold?: number; }; export const SignaturePad = ({ - className, - containerClassName, - defaultValue, + value = '', onChange, disabled = false, - allowTypedSignature, - onValidityChange, - minCoverageThreshold = 0.01, - ...props + typedSignatureEnabled = true, + uploadSignatureEnabled = true, + drawSignatureEnabled = true, }: SignaturePadProps) => { - const $el = useRef(null); - const $imageData = useRef(null); - const $fileInput = useRef(null); + const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : ''); + const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : ''); + const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value); - const [isPressed, setIsPressed] = useState(false); - const [lines, setLines] = useState([]); - const [currentLine, setCurrentLine] = useState([]); - const [selectedColor, setSelectedColor] = useState('black'); - const [typedSignature, setTypedSignature] = useState( - defaultValue && !isBase64Image(defaultValue) ? defaultValue : '', + /** + * This is cooked. + * + * Get the first enabled tab that has a signature if possible, otherwise just get + * the first enabled tab. + */ + const [tab, setTab] = useState( + ((): 'draw' | 'text' | 'image' => { + // First passthrough to check to see if there's a signature for a given tab. + if (drawSignatureEnabled && drawSignature) { + return 'draw'; + } + + if (typedSignatureEnabled && typedSignature) { + return 'text'; + } + + if (uploadSignatureEnabled && imageSignature) { + return 'image'; + } + + // Second passthrough to just select the first avaliable tab. + if (drawSignatureEnabled) { + return 'draw'; + } + + if (typedSignatureEnabled) { + return 'text'; + } + + if (uploadSignatureEnabled) { + return 'image'; + } + + throw new Error('No signature enabled'); + })(), ); - const perfectFreehandOptions = useMemo(() => { - const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10; + const onImageSignatureChange = (value: string) => { + setImageSignature(value); - return { - size, - thinning: 0.25, - streamline: 0.5, - smoothing: 0.5, - end: { - taper: size * 2, - }, - } satisfies StrokeOptions; - }, []); - - const checkSignatureValidity = () => { - if ($el.current) { - const ctx = $el.current.getContext('2d'); - - if (ctx) { - const imageData = ctx.getImageData(0, 0, $el.current.width, $el.current.height); - const data = imageData.data; - let filledPixels = 0; - const totalPixels = data.length / 4; - - for (let i = 0; i < data.length; i += 4) { - if (data[i + 3] > 0) filledPixels++; - } - - const filledPercentage = filledPixels / totalPixels; - const isValid = filledPercentage > minCoverageThreshold; - onValidityChange?.(isValid); - - return isValid; - } - } + onChange?.({ + type: DocumentSignatureType.UPLOAD, + value, + }); }; - const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => { - if (event.cancelable) { - event.preventDefault(); - } + const onDrawSignatureChange = (value: string) => { + setDrawSignature(value); - setIsPressed(true); - - if (typedSignature) { - setTypedSignature(''); - if ($el.current) { - const ctx = $el.current.getContext('2d'); - ctx?.clearRect(0, 0, $el.current.width, $el.current.height); - } - } - - const point = Point.fromEvent(event, DPI, $el.current); - - setCurrentLine([point]); + onChange?.({ + type: DocumentSignatureType.DRAW, + value, + }); }; - const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => { - if (event.cancelable) { - event.preventDefault(); - } + const onTypedSignatureChange = (value: string) => { + setTypedSignature(value); - if (!isPressed) { + onChange?.({ + type: DocumentSignatureType.TYPE, + value, + }); + }; + + const onTabChange = (value: 'draw' | 'text' | 'image') => { + if (disabled) { return; } - const point = Point.fromEvent(event, DPI, $el.current); - const lastPoint = currentLine[currentLine.length - 1]; + setTab(value); - if (lastPoint && point.distanceTo(lastPoint) > 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'; - ctx.fillStyle = selectedColor; - - 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); - } - } - } + match(value) + .with('draw', () => { + onDrawSignatureChange(drawSignature); + }) + .with('text', () => { + onTypedSignatureChange(typedSignature); + }) + .with('image', () => { + onImageSignatureChange(imageSignature); + }) + .exhaustive(); }; - 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'; - ctx.fillStyle = selectedColor; - - newLines.forEach((line) => { - const pathData = new Path2D( - getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), - ); - ctx.fill(pathData); - }); - - const isValidSignature = checkSignatureValidity(); - - if (isValidSignature) { - 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(); - } - - if (isPressed) { - onMouseUp(event, true); - } else { - onMouseUp(event, false); - } - }; - - const onClearClick = () => { - if ($el.current) { - const ctx = $el.current.getContext('2d'); - - ctx?.clearRect(0, 0, $el.current.width, $el.current.height); - $imageData.current = null; - } - - if ($fileInput.current) { - $fileInput.current.value = ''; - } - - onChange?.(null); - - setTypedSignature(''); - setLines([]); - setCurrentLine([]); - setIsPressed(false); - }; - - const renderTypedSignature = () => { - if ($el.current && typedSignature) { - const ctx = $el.current.getContext('2d'); - - if (ctx) { - const canvasWidth = $el.current.width; - const canvasHeight = $el.current.height; - const fontFamily = 'Caveat'; - - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = selectedColor; - - // Calculate the desired width (25ch) - const desiredWidth = canvasWidth * 0.85; // 85% of canvas width - - // Start with a base font size - let fontSize = 18; - ctx.font = `${fontSize}px ${fontFamily}`; - - // Measure 10 characters and calculate scale factor - const characterWidth = ctx.measureText('m'.repeat(10)).width; - const scaleFactor = desiredWidth / characterWidth; - - // Apply scale factor to font size - fontSize = fontSize * scaleFactor; - - // Adjust font size if it exceeds canvas width - ctx.font = `${fontSize}px ${fontFamily}`; - - const textWidth = ctx.measureText(typedSignature).width; - - if (textWidth > desiredWidth) { - fontSize = fontSize * (desiredWidth / textWidth); - } - - // Set final font and render text - ctx.font = `${fontSize}px ${fontFamily}`; - ctx.fillText(typedSignature, canvasWidth / 2, canvasHeight / 2); - } - } - }; - - const handleTypedSignatureChange = (event: React.ChangeEvent) => { - const newValue = event.target.value; - - // Deny input while drawing. - if (isPressed) { - return; - } - - if (lines.length > 0) { - setLines([]); - setCurrentLine([]); - } - - setTypedSignature(newValue); - - if ($el.current) { - const ctx = $el.current.getContext('2d'); - ctx?.clearRect(0, 0, $el.current.width, $el.current.height); - } - - if (newValue.trim() !== '') { - onChange?.(newValue); - onValidityChange?.(true); - } else { - onChange?.(null); - onValidityChange?.(false); - } - }; - - const handleImageUpload = async (event: React.ChangeEvent) => { - try { - const img = await loadImage(event.target.files?.[0]); - - if (!$el.current) return; - - const ctx = $el.current.getContext('2d'); - if (!ctx) return; - - $imageData.current = loadImageOntoCanvas(img, $el.current, ctx); - onChange?.($el.current.toDataURL()); - - setLines([]); - setCurrentLine([]); - setTypedSignature(''); - } catch (error) { - console.error(error); - } - }; - - useEffect(() => { - if (typedSignature.trim() !== '' && !isBase64Image(typedSignature)) { - renderTypedSignature(); - onChange?.(typedSignature); - } - }, [typedSignature, selectedColor]); - - const onUndoClick = () => { - if (lines.length === 0 && typedSignature.length === 0) { - return; - } - - if (typedSignature.length > 0) { - const newTypedSignature = typedSignature.slice(0, -1); - setTypedSignature(newTypedSignature); - // You might want to call onChange here as well - // onChange?.(newTypedSignature); - } else { - const newLines = lines.slice(0, -1); - setLines(newLines); - - // Clear and redraw the canvas - if ($el.current) { - const ctx = $el.current.getContext('2d'); - const { width, height } = $el.current; - ctx?.clearRect(0, 0, width, height); - - if (typeof defaultValue === 'string' && $imageData.current) { - ctx?.putImageData($imageData.current, 0, 0); - } - - newLines.forEach((line) => { - const pathData = new Path2D( - getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), - ); - ctx?.fill(pathData); - }); - - onChange?.($el.current.toDataURL()); - } - } - }; - - useEffect(() => { - if ($el.current) { - $el.current.width = $el.current.clientWidth * DPI; - $el.current.height = $el.current.clientHeight * DPI; - } - - if (defaultValue && typedSignature) { - renderTypedSignature(); - } - }, []); - - unsafe_useEffectOnce(() => { - 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)); - - const defaultImageData = ctx?.getImageData(0, 0, width, height) || null; - - $imageData.current = defaultImageData; - }; - - img.src = defaultValue; - } - }); + if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) { + return null; + } return ( -
onTabChange(value as 'draw' | 'text' | 'image')} > - + {drawSignatureEnabled && ( + + + Draw + )} - style={{ touchAction: 'none' }} - onPointerMove={(event) => onMouseMove(event)} - onPointerDown={(event) => onMouseDown(event)} - onPointerUp={(event) => onMouseUp(event)} - onPointerLeave={(event) => onMouseLeave(event)} - onPointerEnter={(event) => onMouseEnter(event)} - {...props} - /> - {allowTypedSignature && ( -
0 || typedSignature.length > 0, - })} - > - -
- )} + {typedSignatureEnabled && ( + + + Type + + )} -
-
$fileInput.current?.click()} - > - - - - Upload Signature - -
-
+ {uploadSignatureEnabled && ( + + + Upload + + )} + -
- -
- -
- -
- - {(lines.length > 0 || typedSignature.length > 0) && ( -
- -
- )} -
+ + + + ); }; diff --git a/packages/ui/primitives/signature-pad/signature-render.tsx b/packages/ui/primitives/signature-pad/signature-render.tsx new file mode 100644 index 000000000..ba8c05ec2 --- /dev/null +++ b/packages/ui/primitives/signature-pad/signature-render.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from 'react'; + +import { SIGNATURE_CANVAS_DPI, isBase64Image } from '@documenso/lib/constants/signatures'; + +import { cn } from '../../lib/utils'; + +export type SignatureRenderProps = { + className?: string; + value: string; +}; + +/** + * Renders a typed, uploaded or drawn signature. + */ +export const SignatureRender = ({ className, value }: SignatureRenderProps) => { + const $el = useRef(null); + const $imageData = useRef(null); + + const renderTypedSignature = () => { + if (!$el.current) { + return; + } + + const ctx = $el.current.getContext('2d'); + + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, $el.current.width, $el.current.height); + + const canvasWidth = $el.current.width; + const canvasHeight = $el.current.height; + const fontFamily = 'Caveat'; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + // ctx.fillStyle = selectedColor; // Todo: Color not implemented... + + // Calculate the desired width (25ch) + const desiredWidth = canvasWidth * 0.85; // 85% of canvas width + + // Start with a base font size + let fontSize = 18; + ctx.font = `${fontSize}px ${fontFamily}`; + + // Measure 10 characters and calculate scale factor + const characterWidth = ctx.measureText('m'.repeat(10)).width; + const scaleFactor = desiredWidth / characterWidth; + + // Apply scale factor to font size + fontSize = fontSize * scaleFactor; + + // Adjust font size if it exceeds canvas width + ctx.font = `${fontSize}px ${fontFamily}`; + + const textWidth = ctx.measureText(value).width; + + if (textWidth > desiredWidth) { + fontSize = fontSize * (desiredWidth / textWidth); + } + + // Set final font and render text + ctx.font = `${fontSize}px ${fontFamily}`; + ctx.fillText(value, canvasWidth / 2, canvasHeight / 2); + }; + + const renderImageSignature = () => { + if (!$el.current || typeof value !== 'string') { + return; + } + + const ctx = $el.current.getContext('2d'); + + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, $el.current.width, $el.current.height); + + const { width, height } = $el.current; + + const img = new Image(); + + img.onload = () => { + // Calculate the scaled dimensions while maintaining aspect ratio + const scale = Math.min(width / img.width, height / img.height); + const scaledWidth = img.width * scale; + const scaledHeight = img.height * scale; + + // Calculate center position + const x = (width - scaledWidth) / 2; + const y = (height - scaledHeight) / 2; + + ctx?.drawImage(img, x, y, scaledWidth, scaledHeight); + + const defaultImageData = ctx?.getImageData(0, 0, width, height) || null; + + $imageData.current = defaultImageData; + }; + + img.src = value; + }; + + useEffect(() => { + if ($el.current) { + $el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI; + $el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI; + } + }, []); + + useEffect(() => { + if (isBase64Image(value)) { + renderImageSignature(); + } else { + renderTypedSignature(); + } + }, [value]); + + return ( + + ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-tabs.tsx b/packages/ui/primitives/signature-pad/signature-tabs.tsx new file mode 100644 index 000000000..35b988311 --- /dev/null +++ b/packages/ui/primitives/signature-pad/signature-tabs.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; + +import { motion } from 'framer-motion'; + +import { cn } from '../../lib/utils'; + +interface TabsProps extends React.HTMLAttributes { + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; + children: React.ReactNode; +} + +interface TabsContextValue { + value: string; + onValueChange: (value: string) => void; +} + +const TabsContext = React.createContext(undefined); + +function useTabs() { + const context = React.useContext(TabsContext); + if (!context) { + throw new Error('useTabs must be used within a Tabs provider'); + } + return context; +} + +export function Tabs({ + defaultValue, + value, + onValueChange, + children, + className, + ...props +}: TabsProps) { + const [tabValue, setTabValue] = React.useState(defaultValue || ''); + + const handleValueChange = React.useCallback( + (newValue: string) => { + setTabValue(newValue); + onValueChange?.(newValue); + }, + [onValueChange], + ); + + const contextValue = React.useMemo( + () => ({ + value: value !== undefined ? value : tabValue, + onValueChange: handleValueChange, + }), + [value, tabValue, handleValueChange], + ); + + return ( + +
+ {children} +
+
+ ); +} + +interface TabsListProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +export function TabsList({ children, className, ...props }: TabsListProps) { + return ( +
+ {children} +
+ ); +} + +interface TabsTriggerProps extends React.ButtonHTMLAttributes { + value: string; + icon?: React.ReactNode; + children: React.ReactNode; +} + +export function TabsTrigger({ value, icon, children, className, ...props }: TabsTriggerProps) { + const { value: selectedValue, onValueChange } = useTabs(); + const isSelected = selectedValue === value; + + return ( + + ); +} + +interface TabsContentProps extends React.HTMLAttributes { + value: string; + children: React.ReactNode; +} + +export function TabsContent({ value, children, className, ...props }: TabsContentProps) { + const { value: selectedValue } = useTabs(); + const isSelected = selectedValue === value; + + if (!isSelected) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index 88ae949f2..f82c9656f 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -54,10 +54,9 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive import { useToast } from '@documenso/ui/primitives/use-toast'; import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors'; -import { Checkbox } from '../checkbox'; import type { FieldFormType } from '../document-flow/add-fields'; import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings'; -import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form'; +import { Form } from '../form/form'; import { useStep } from '../stepper'; import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types'; @@ -74,7 +73,6 @@ export type AddTemplateFieldsFormProps = { fields: Field[]; onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; teamId?: number; - typedSignatureEnabled?: boolean; }; export const AddTemplateFieldsFormPartial = ({ @@ -84,7 +82,6 @@ export const AddTemplateFieldsFormPartial = ({ fields, onSubmit, teamId, - typedSignatureEnabled, }: AddTemplateFieldsFormProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -119,7 +116,6 @@ export const AddTemplateFieldsFormPartial = ({ recipients.find((recipient) => recipient.id === field.recipientId)?.token ?? '', fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, })), - typedSignatureEnabled: typedSignatureEnabled ?? false, }, }); @@ -483,12 +479,6 @@ export const AddTemplateFieldsFormPartial = ({ form.setValue('fields', updatedFields); }; - const isTypedSignatureEnabled = form.watch('typedSignatureEnabled'); - - const handleTypedSignatureChange = (value: boolean) => { - form.setValue('typedSignatureEnabled', value, { shouldDirty: true }); - }; - return ( <> {showAdvancedSettings && currentField ? ( @@ -662,31 +652,6 @@ export const AddTemplateFieldsFormPartial = ({ )} - ( - - - field.onChange(checked)} - disabled={form.formState.isSubmitting} - /> - - - - Enable Typed Signatures - - - )} - /> -