mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
feat: add signature configurations (#1710)
Add ability to enable or disable allowed signature types: - Drawn - Typed - Uploaded **Tabbed style signature dialog**  **Document settings**  **Team preferences**  ## 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:
@ -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
|
||||
|
||||
@ -18,7 +18,6 @@ export const ZAddFieldsFormSchema = z.object({
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type TAddFieldsFormSchema = z.infer<typeof ZAddFieldsFormSchema>;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
150
packages/ui/primitives/signature-pad/signature-pad-dialog.tsx
Normal file
150
packages/ui/primitives/signature-pad/signature-pad-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
327
packages/ui/primitives/signature-pad/signature-pad-draw.tsx
Normal file
327
packages/ui/primitives/signature-pad/signature-pad-draw.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
packages/ui/primitives/signature-pad/signature-pad-type.tsx
Normal file
29
packages/ui/primitives/signature-pad/signature-pad-type.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
166
packages/ui/primitives/signature-pad/signature-pad-upload.tsx
Normal file
166
packages/ui/primitives/signature-pad/signature-pad-upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
128
packages/ui/primitives/signature-pad/signature-render.tsx
Normal file
128
packages/ui/primitives/signature-pad/signature-render.tsx
Normal 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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
147
packages/ui/primitives/signature-pad/signature-tabs.tsx
Normal file
147
packages/ui/primitives/signature-pad/signature-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -20,7 +20,6 @@ export const ZAddTemplateFieldsFormSchema = z.object({
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type TAddTemplateFieldsFormSchema = z.infer<typeof ZAddTemplateFieldsFormSchema>;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user