feat: add signature configurations (#1710)

Add ability to enable or disable allowed signature types: 
- Drawn
- Typed
- Uploaded

**Tabbed style signature dialog**

![image](https://github.com/user-attachments/assets/a816fab6-b071-42a5-bb5c-6d4a2572431e)

**Document settings**

![image](https://github.com/user-attachments/assets/f0c1bff1-6be1-4c87-b384-1666fa25d7a6)

**Team preferences**

![image](https://github.com/user-attachments/assets/8767b05e-1463-4087-8672-f3f43d8b0f2c)

## Changes Made

- Add multiselect to select allowed signatures in document and templates
settings tab
- Add multiselect to select allowed signatures in teams preferences 
- Removed "Enable typed signatures" from document/template edit page
- Refactored signature pad to use tabs instead of an all in one
signature pad

## Testing Performed

Added E2E tests to check settings are applied correctly for documents
and templates
This commit is contained in:
David Nguyen
2025-03-24 15:25:29 +11:00
committed by GitHub
parent 1b5d24e308
commit 3e97643e7e
78 changed files with 2390 additions and 1112 deletions

View File

@ -47,9 +47,8 @@ import { cn } from '../../lib/utils';
import { Alert, AlertDescription } from '../alert';
import { Button } from '../button';
import { Card, CardContent } from '../card';
import { Checkbox } from '../checkbox';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
import { Form } from '../form/form';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
@ -94,7 +93,6 @@ export type AddFieldsFormProps = {
onSubmit: (_data: TAddFieldsFormSchema) => void;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
typedSignatureEnabled?: boolean;
teamId?: number;
};
@ -106,7 +104,6 @@ export const AddFieldsFormPartial = ({
onSubmit,
canGoBack = false,
isDocumentPdfLoaded,
typedSignatureEnabled,
teamId,
}: AddFieldsFormProps) => {
const { toast } = useToast();
@ -137,7 +134,6 @@ export const AddFieldsFormPartial = ({
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
typedSignatureEnabled: typedSignatureEnabled ?? false,
},
});
@ -787,31 +783,6 @@ export const AddFieldsFormPartial = ({
)}
<Form {...form}>
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="typedSignatureEnabled"
checked={value}
onCheckedChange={(checked) => field.onChange(checked)}
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormLabel
htmlFor="typedSignatureEnabled"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable Typed Signatures</Trans>
</FormLabel>
</FormItem>
)}
/>
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<fieldset disabled={isFieldsDisabled} className="my-2 grid grid-cols-3 gap-4">
<button

View File

@ -18,7 +18,6 @@ export const ZAddFieldsFormSchema = z.object({
fieldMeta: ZFieldMetaSchema,
}),
),
typedSignatureEnabled: z.boolean(),
});
export type TAddFieldsFormSchema = z.infer<typeof ZAddFieldsFormSchema>;

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
@ -9,10 +10,12 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDocument } from '@documenso/lib/types/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import {
DocumentGlobalAuthAccessSelect,
DocumentGlobalAuthAccessTooltip,
@ -39,7 +42,9 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
@ -78,6 +83,8 @@ export const AddSettingsFormPartial = ({
currentTeamMemberRole,
onSubmit,
}: AddSettingsFormProps) => {
const { t } = useLingui();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
@ -90,6 +97,7 @@ export const AddSettingsFormPartial = ({
visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
timezone:
TIME_ZONES.find((timezone) => timezone === document.documentMeta?.timezone) ??
@ -99,6 +107,7 @@ export const AddSettingsFormPartial = ({
?.value ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
language: document.documentMeta?.language ?? 'en',
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
},
},
});
@ -189,9 +198,11 @@ export const AddSettingsFormPartial = ({
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
Controls the language for the document, including the language to be used
for email notifications, and the final certificate that is generated and
attached to the document.
<Trans>
Controls the language for the document, including the language to be used
for email notifications, and the final certificate that is generated and
attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
@ -314,6 +325,34 @@ export const AddSettingsFormPartial = ({
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.dateFormat"

View File

@ -1,7 +1,9 @@
import { msg } from '@lingui/core/macro';
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
@ -26,7 +28,10 @@ export const ZMapNegativeOneToUndefinedSchema = z
});
export const ZAddSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
title: z
.string()
.trim()
.min(1, { message: msg`Title cannot be empty`.id }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
@ -49,6 +54,9 @@ export const ZAddSettingsFormSchema = z.object({
.union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)])
.optional()
.default('en'),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
}),
});

View File

@ -24,11 +24,14 @@ type ComboBoxOption<T = OptionValue> = {
type MultiSelectComboboxProps<T = OptionValue> = {
emptySelectionPlaceholder?: React.ReactElement | string;
enableClearAllButton?: boolean;
enableSearch?: boolean;
className?: string;
loading?: boolean;
inputPlaceholder?: MessageDescriptor;
onChange: (_values: T[]) => void;
options: ComboBoxOption<T>[];
selectedValues: T[];
testId?: string;
};
/**
@ -41,11 +44,14 @@ type MultiSelectComboboxProps<T = OptionValue> = {
export function MultiSelectCombobox<T = OptionValue>({
emptySelectionPlaceholder = 'Select values...',
enableClearAllButton,
enableSearch = true,
className,
inputPlaceholder,
loading,
onChange,
options,
selectedValues,
testId,
}: MultiSelectComboboxProps<T>) {
const { _ } = useLingui();
@ -59,8 +65,6 @@ export function MultiSelectCombobox<T = OptionValue>({
}
onChange(newSelectedOptions);
setOpen(false);
};
const selectedOptions = React.useMemo(() => {
@ -107,7 +111,8 @@ export function MultiSelectCombobox<T = OptionValue>({
role="combobox"
disabled={loading}
aria-expanded={open}
className="w-[200px] px-3"
className={cn('w-[200px] px-3', className)}
data-testid={testId}
>
<AnimatePresence>
{loading ? (
@ -146,7 +151,7 @@ export function MultiSelectCombobox<T = OptionValue>({
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={inputPlaceholder && _(inputPlaceholder)} />
{enableSearch && <CommandInput placeholder={inputPlaceholder && _(inputPlaceholder)} />}
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>

View File

@ -1,3 +1,5 @@
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
import { Point } from './point';
export class Canvas {
@ -14,7 +16,7 @@ export class Canvas {
private lastVelocity = 0;
private readonly VELOCITY_FILTER_WEIGHT = 0.5;
private readonly DPI = 2;
private readonly DPI = SIGNATURE_CANVAS_DPI;
constructor(canvas: HTMLCanvasElement) {
this.$canvas = canvas;

View File

@ -0,0 +1,63 @@
import { Trans } from '@lingui/react/macro';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { cn } from '../../lib/utils';
export type SignaturePadColorPickerProps = {
selectedColor: string;
setSelectedColor: (color: string) => void;
className?: string;
};
export const SignaturePadColorPicker = ({
selectedColor,
setSelectedColor,
className,
}: SignaturePadColorPickerProps) => {
return (
<div className={cn('text-foreground absolute right-2 top-2 filter', className)}>
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
<SelectTrigger className="h-auto w-auto border-none p-0.5">
<SelectValue placeholder="" />
</SelectTrigger>
<SelectContent className="w-[100px]" align="end">
<SelectItem value="black">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-black shadow-sm" />
<Trans>Black</Trans>
</div>
</SelectItem>
<SelectItem value="red">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[red] shadow-sm" />
<Trans>Red</Trans>
</div>
</SelectItem>
<SelectItem value="blue">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[blue] shadow-sm" />
<Trans>Blue</Trans>
</div>
</SelectItem>
<SelectItem value="green">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[green] shadow-sm" />
<Trans>Green</Trans>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -0,0 +1,150 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, useLingui } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { SignaturePad } from './signature-pad';
import { SignatureRender } from './signature-render';
export type SignaturePadDialogProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
disabled?: boolean;
value?: string;
onChange: (_value: string) => void;
dialogConfirmText?: MessageDescriptor | string;
disableAnimation?: boolean;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
};
export const SignaturePadDialog = ({
className,
value,
onChange,
disabled = false,
disableAnimation = false,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
dialogConfirmText,
}: SignaturePadDialogProps) => {
const { i18n } = useLingui();
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [signature, setSignature] = useState<string>(value ?? '');
return (
<div
className={cn(
'aspect-signature-pad bg-background relative block w-full select-none rounded-lg border',
className,
{
'pointer-events-none opacity-50': disabled,
},
)}
>
{value && (
<div className="inset-0 h-full w-full">
<SignatureRender value={value} />
</div>
)}
<motion.button
data-testid="signature-pad-dialog-button"
type="button"
disabled={disabled}
className="absolute inset-0 flex items-center justify-center bg-transparent"
onClick={() => setShowSignatureModal(true)}
whileHover="onHover"
>
{!value && !disableAnimation && (
<motion.svg
width="120"
height="120"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-muted-foreground/60"
variants={{
onHover: {
scale: 1.1,
transition: {
type: 'spring',
stiffness: 300,
damping: 12,
mass: 0.8,
restDelta: 0.001,
},
},
}}
>
<motion.path
d="M1.5 11H14.5M1.5 14C1.5 14 8.72 2 4.86938 2H4.875C2.01 2 1.97437 14.0694 8 6.51188V6.5C8 6.5 9 11.3631 11.5 7.52375V7.5C11.5 7.5 11.5 9 14.5 9"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: 1,
opacity: 1,
transition: {
pathLength: {
duration: 2,
ease: 'easeInOut',
},
opacity: { duration: 0.6 },
},
}}
/>
</motion.svg>
)}
</motion.button>
<Dialog open={showSignatureModal} onOpenChange={disabled ? undefined : setShowSignatureModal}>
<DialogContent hideClose={true} className="p-6 pt-4">
<SignaturePad
id="signature"
value={value}
className={className}
disabled={disabled}
onChange={({ value }) => setSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
disabled={!signature}
onClick={() => {
onChange(signature);
setShowSignatureModal(false);
}}
>
{dialogConfirmText ? (
parseMessageDescriptor(i18n._, dialogConfirmText)
) : (
<Trans>Next</Trans>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const $fileInput = useRef<HTMLInputElement>(null);
const [isPressed, setIsPressed] = useState(false);
const [lines, setLines] = useState<Point[][]>([]);
const [currentLine, setCurrentLine] = useState<Point[]>([]);
const [isSignatureValid, setIsSignatureValid] = useState<boolean | null>(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 (
<div className={cn('h-full w-full', className)}>
<canvas
data-testid="signature-pad-draw"
ref={$el}
className={cn('h-full w-full', {
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
})}
style={{ touchAction: 'none' }}
onPointerMove={(event) => onMouseMove(event)}
onPointerDown={(event) => onMouseDown(event)}
onPointerUp={(event) => onMouseUp(event)}
onPointerLeave={(event) => onMouseLeave(event)}
onPointerEnter={(event) => onMouseEnter(event)}
{...props}
/>
<SignaturePadColorPicker selectedColor={selectedColor} setSelectedColor={setSelectedColor} />
<div className="absolute bottom-3 right-3 flex gap-2">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()}
>
<Trans>Clear Signature</Trans>
</button>
</div>
{isSignatureValid === false && (
<div className="absolute bottom-4 left-4 flex gap-2">
<span className="text-destructive text-xs">
<Trans>Signature is too small</Trans>
</span>
</div>
)}
{isSignatureValid && lines.length > 0 && (
<div className="absolute bottom-4 left-4 flex gap-2">
<button
type="button"
title="undo"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={onUndoClick}
>
<Undo2 className="h-4 w-4" />
<span className="sr-only">Undo</span>
</button>
</div>
)}
</div>
);
};

View File

@ -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 (
<div className={cn('flex h-full w-full items-center justify-center', className)}>
<input
data-testid="signature-pad-type-input"
placeholder="Type your signature"
className="font-signature w-full bg-transparent px-4 text-center text-7xl text-black placeholder:text-4xl focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white"
// style={{ color: selectedColor }}
value={value}
onChange={(event) => onChange(event.target.value.trimStart())}
/>
{/* <SignaturePadColorPicker selectedColor={selectedColor} setSelectedColor={setSelectedColor} /> */}
</div>
);
};

View File

@ -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<HTMLImageElement> => {
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<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const $fileInput = useRef<HTMLInputElement>(null);
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={cn('relative h-full w-full', className)}>
<canvas
data-testid="signature-pad-upload"
ref={$el}
className="h-full w-full dark:hue-rotate-180 dark:invert"
style={{ touchAction: 'none' }}
{...props}
/>
<input
ref={$fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
<motion.button
className="absolute inset-0 flex h-full w-full items-center justify-center"
initial="initial"
animate="animate"
whileHover="hover"
onClick={() => $fileInput.current?.click()}
>
{!value && (
<motion.div>
<div className="text-muted-foreground flex flex-col items-center justify-center">
<div className="flex flex-col items-center">
<UploadCloudIcon className="h-8 w-8" />
<span className="text-lg font-semibold">
<Trans>Upload Signature</Trans>
</span>
</div>
</div>
</motion.div>
)}
</motion.button>
</div>
);
};

View File

@ -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<HTMLImageElement> => {
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<HTMLAttributes<HTMLCanvasElement>, '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<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const $fileInput = useRef<HTMLInputElement>(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<Point[][]>([]);
const [currentLine, setCurrentLine] = useState<Point[]>([]);
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div
className={cn('relative block select-none', containerClassName, {
'pointer-events-none opacity-50': disabled,
<Tabs
defaultValue={tab}
className={cn({
'pointer-events-none': disabled,
})}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
>
<canvas
data-testid="signature-pad"
ref={$el}
className={cn(
'relative block',
{
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
},
className,
<TabsList>
{drawSignatureEnabled && (
<TabsTrigger value="draw">
<SignatureIcon className="mr-2 size-4" />
Draw
</TabsTrigger>
)}
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 && (
<div
className={cn('ml-4 pb-1', {
'ml-10': lines.length > 0 || typedSignature.length > 0,
})}
>
<Input
placeholder="Type your signature"
className="w-1/2 border-none p-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
value={typedSignature}
onChange={handleTypedSignatureChange}
/>
</div>
)}
{typedSignatureEnabled && (
<TabsTrigger value="text">
<KeyboardIcon className="mr-2 size-4" />
Type
</TabsTrigger>
)}
<div className="text-foreground absolute left-3 top-3 filter">
<div
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground flex cursor-pointer flex-row gap-2 rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={() => $fileInput.current?.click()}
>
<Input
ref={$fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={disabled}
/>
<Upload className="h-4 w-4" />
<span>
<Trans>Upload Signature</Trans>
</span>
</div>
</div>
{uploadSignatureEnabled && (
<TabsTrigger value="image">
<UploadCloudIcon className="mr-2 size-4" />
Upload
</TabsTrigger>
)}
</TabsList>
<div className="text-foreground absolute right-2 top-2 filter">
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
<SelectTrigger className="h-auto w-auto border-none p-0.5">
<SelectValue placeholder="" />
</SelectTrigger>
<TabsContent
value="draw"
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
>
<SignaturePadDraw
className="h-full w-full"
onChange={onDrawSignatureChange}
value={drawSignature}
/>
</TabsContent>
<SelectContent className="w-[100px]" align="end">
<SelectItem value="black">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-black shadow-sm" />
<Trans>Black</Trans>
</div>
</SelectItem>
<TabsContent
value="text"
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
>
<SignaturePadType value={typedSignature} onChange={onTypedSignatureChange} />
</TabsContent>
<SelectItem value="red">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[red] shadow-sm" />
<Trans>Red</Trans>
</div>
</SelectItem>
<SelectItem value="blue">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[blue] shadow-sm" />
<Trans>Blue</Trans>
</div>
</SelectItem>
<SelectItem value="green">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[green] shadow-sm" />
<Trans>Green</Trans>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="absolute bottom-3 right-3 flex gap-2">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()}
>
<Trans>Clear Signature</Trans>
</button>
</div>
{(lines.length > 0 || typedSignature.length > 0) && (
<div className="absolute bottom-4 left-4 flex gap-2">
<button
type="button"
title="undo"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={onUndoClick}
>
<Undo2 className="h-4 w-4" />
<span className="sr-only">Undo</span>
</button>
</div>
)}
</div>
<TabsContent
value="image"
className={cn(
'border-border aspect-signature-pad dark:bg-background relative rounded-md border bg-neutral-50',
{
'bg-white': imageSignature,
},
)}
>
<SignaturePadUpload value={imageSignature} onChange={onImageSignatureChange} />
</TabsContent>
</Tabs>
);
};

View File

@ -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<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(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 (
<canvas
ref={$el}
className={cn('h-full w-full dark:hue-rotate-180 dark:invert', className)}
style={{ touchAction: 'none' }}
/>
);
};

View File

@ -0,0 +1,147 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '../../lib/utils';
interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
}
interface TabsContextValue {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | undefined>(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 (
<TabsContext.Provider value={contextValue}>
<div className={cn('w-full', className)} {...props}>
{children}
</div>
</TabsContext.Provider>
);
}
interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export function TabsList({ children, className, ...props }: TabsListProps) {
return (
<div
className={cn('border-border flex flex-wrap border-b', className)}
role="tabslist"
{...props}
>
{children}
</div>
);
}
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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 (
<button
role="tab"
type="button"
aria-selected={isSelected}
data-state={isSelected ? 'active' : 'inactive'}
onClick={() => onValueChange(value)}
className={cn(
'relative flex items-center px-4 py-3 text-sm font-medium transition-all',
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
isSelected ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
className,
)}
{...props}
>
{icon && <span className="flex items-center">{icon}</span>}
{children}
{isSelected && (
<motion.div
layoutId="activeTabIndicator"
className="bg-foreground/40 absolute bottom-0 left-0 h-0.5 w-full rounded-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 50,
}}
/>
)}
</button>
);
}
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
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 (
<div
role="tabpanel"
data-state={isSelected ? 'active' : 'inactive'}
className={cn('mt-4', className)}
{...props}
>
{children}
</div>
);
}

View File

@ -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 = ({
)}
<Form {...form}>
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="typedSignatureEnabled"
checked={value}
onCheckedChange={(checked) => field.onChange(checked)}
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormLabel
htmlFor="typedSignatureEnabled"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable Typed Signatures</Trans>
</FormLabel>
</FormItem>
)}
/>
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<fieldset className="my-2 grid grid-cols-3 gap-4">
<button

View File

@ -20,7 +20,6 @@ export const ZAddTemplateFieldsFormSchema = z.object({
fieldMeta: ZFieldMetaSchema,
}),
),
typedSignatureEnabled: z.boolean(),
});
export type TAddTemplateFieldsFormSchema = z.infer<typeof ZAddTemplateFieldsFormSchema>;

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
@ -10,12 +10,16 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_DISTRIBUTION_METHODS } from '@documenso/lib/constants/document';
import {
DOCUMENT_DISTRIBUTION_METHODS,
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import type { TDocumentMetaDateFormat } from '@documenso/trpc/server/document-router/schema';
import {
DocumentGlobalAuthAccessSelect,
@ -46,6 +50,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes';
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import {
DocumentFlowFormContainerActions,
@ -57,6 +62,7 @@ import {
import { ShowFieldItem } from '../document-flow/show-field-item';
import type { DocumentFlowStep } from '../document-flow/types';
import { Input } from '../input';
import { MultiSelectCombobox } from '../multi-select-combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
@ -85,7 +91,7 @@ export const AddTemplateSettingsFormPartial = ({
currentTeamMemberRole,
onSubmit,
}: AddTemplateSettingsFormProps) => {
const { _ } = useLingui();
const { t, i18n } = useLingui();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
@ -111,6 +117,7 @@ export const AddTemplateSettingsFormPartial = ({
redirectUrl: template.templateMeta?.redirectUrl ?? '',
language: template.templateMeta?.language ?? 'en',
emailSettings: ZDocumentEmailSettingsSchema.parse(template?.templateMeta?.emailSettings),
signatureTypes: extractTeamSignatureSettings(template?.templateMeta),
},
},
});
@ -314,7 +321,7 @@ export const AddTemplateSettingsFormPartial = ({
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{_(description)}
{i18n._(description)}
</SelectItem>
),
)}
@ -325,6 +332,34 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isEnterprise && (
<FormField
control={form.control}

View File

@ -1,8 +1,10 @@
import { msg } from '@lingui/core/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
@ -49,6 +51,9 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.optional()
.default('en'),
emailSettings: ZDocumentEmailSettingsSchema,
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
}),
});