Compare commits

...

6 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 357017314d feat: add digitized signatures 2025-08-19 08:48:04 +00:00
David Nguyen a51110d276 fix: prevent document unsigning on edit (#1963) 2025-08-18 13:48:51 +10:00
David Nguyen 7f81231467 fix: template e2e tests (#1969) 2025-08-18 12:42:36 +10:00
Lucas Smith 439262fd02 v1.12.2-rc.4 2025-08-16 19:16:29 +10:00
Lucas Smith 93a184355b chore: add translations (#1955) 2025-08-16 19:10:21 +10:00
David Nguyen 1dea0b8fab add dummy teamid (#1968) 2025-08-16 19:09:21 +10:00
22 changed files with 2002 additions and 1072 deletions
@@ -36,6 +36,7 @@ export type DocumentSigningSignatureFieldProps = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
keyboardSignatureEnabled?: boolean;
};
export const DocumentSigningSignatureField = ({
@@ -45,6 +46,7 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
keyboardSignatureEnabled,
}: DocumentSigningSignatureFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -283,6 +285,7 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
keyboardSignatureEnabled={keyboardSignatureEnabled}
/>
<DocumentSigningDisclosure />
+1 -1
View File
@@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.2-rc.3"
"version": "1.12.2-rc.4"
}
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"workspaces": [
"apps/*",
"packages/*"
@@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -144,10 +144,11 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.getByRole('button', { name: 'Use Template' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
// Get input with Email label placeholder.
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
+7
View File
@@ -69,4 +69,11 @@ export const DOCUMENT_SIGNATURE_TYPES = {
}),
value: DocumentSignatureType.UPLOAD,
},
[DocumentSignatureType.KEYBOARD]: {
label: msg({
message: `Keyboard`,
context: `Keyboard signatute type`,
}),
value: DocumentSignatureType.KEYBOARD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
@@ -111,7 +111,7 @@ export const getDocumentWhereInput = async ({
visibility: {
in: teamVisibilityFilters,
},
teamId,
teamId: team.id,
},
// Or, if they are a recipient of the document.
{
@@ -134,6 +134,9 @@ export const setDocumentRecipients = async ({
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, document.fields);
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
@@ -147,6 +150,7 @@ export const setDocumentRecipients = async ({
return {
...recipient,
_persisted: existing,
canPersistedRecipientBeModified,
};
});
@@ -162,6 +166,13 @@ export const setDocumentRecipients = async ({
});
}
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
return {
...recipient._persisted,
clientId: recipient.clientId,
};
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
@@ -1,7 +1,5 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@@ -104,10 +102,7 @@ export const updateDocumentRecipients = async ({
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
@@ -203,9 +198,6 @@ export const updateDocumentRecipients = async ({
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
@@ -215,19 +207,3 @@ type RecipientData = {
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -18,6 +18,7 @@ export enum DocumentSignatureType {
DRAW = 'draw',
TYPE = 'type',
UPLOAD = 'upload',
KEYBOARD = 'keyboard',
}
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
@@ -83,10 +84,16 @@ export const extractTeamSignatureSettings = (
typedSignatureEnabled: boolean | null;
drawSignatureEnabled: boolean | null;
uploadSignatureEnabled: boolean | null;
keyboardSignatureEnabled?: boolean | null;
} | null,
) => {
if (!settings) {
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
return [
DocumentSignatureType.TYPE,
DocumentSignatureType.UPLOAD,
DocumentSignatureType.DRAW,
DocumentSignatureType.KEYBOARD,
];
}
const signatureTypes: DocumentSignatureType[] = [];
@@ -103,6 +110,10 @@ export const extractTeamSignatureSettings = (
signatureTypes.push(DocumentSignatureType.UPLOAD);
}
if (settings.keyboardSignatureEnabled !== false) {
signatureTypes.push(DocumentSignatureType.KEYBOARD);
}
return signatureTypes;
};
@@ -175,6 +186,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
drawSignatureEnabled: null,
keyboardSignatureEnabled: null,
brandingEnabled: null,
brandingLogo: null,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "keyboardSignatureEnabled" BOOLEAN;
+1
View File
@@ -776,6 +776,7 @@ model TeamGlobalSettings {
typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean?
drawSignatureEnabled Boolean?
keyboardSignatureEnabled Boolean?
emailId String?
email OrganisationEmail? @relation(fields: [emailId], references: [id])
+1
View File
@@ -141,6 +141,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
return await next({
ctx: {
...ctx,
teamId: ctx.teamId || -1,
logger: trpcSessionLogger,
user: ctx.user,
session: ctx.session,
@@ -0,0 +1,207 @@
export enum KeyboardLayout {
QWERTY = 'QWERTY',
}
export type CurveType =
| 'linear'
| 'simple-curve'
| 'quadratic-bezier'
| 'cubic-bezier'
| 'catmull-rom';
export enum StrokeStyle {
SOLID = 'solid',
GRADIENT = 'gradient',
}
export interface StrokeConfig {
style: StrokeStyle;
color: string;
gradientStart: string;
gradientEnd: string;
width: number;
}
export interface Point {
x: number;
y: number;
}
export const getKeyboardLayout = (
layout: KeyboardLayout,
includeNumbers: boolean = false,
): Record<string, Point> => {
const qwertyLayout: Record<string, Point> = {
Q: { x: 0, y: includeNumbers ? 1 : 0 },
W: { x: 1, y: includeNumbers ? 1 : 0 },
E: { x: 2, y: includeNumbers ? 1 : 0 },
R: { x: 3, y: includeNumbers ? 1 : 0 },
T: { x: 4, y: includeNumbers ? 1 : 0 },
Y: { x: 5, y: includeNumbers ? 1 : 0 },
U: { x: 6, y: includeNumbers ? 1 : 0 },
I: { x: 7, y: includeNumbers ? 1 : 0 },
O: { x: 8, y: includeNumbers ? 1 : 0 },
P: { x: 9, y: includeNumbers ? 1 : 0 },
A: { x: 0.5, y: includeNumbers ? 2 : 1 },
S: { x: 1.5, y: includeNumbers ? 2 : 1 },
D: { x: 2.5, y: includeNumbers ? 2 : 1 },
F: { x: 3.5, y: includeNumbers ? 2 : 1 },
G: { x: 4.5, y: includeNumbers ? 2 : 1 },
H: { x: 5.5, y: includeNumbers ? 2 : 1 },
J: { x: 6.5, y: includeNumbers ? 2 : 1 },
K: { x: 7.5, y: includeNumbers ? 2 : 1 },
L: { x: 8.5, y: includeNumbers ? 2 : 1 },
Z: { x: 1, y: includeNumbers ? 3 : 2 },
X: { x: 2, y: includeNumbers ? 3 : 2 },
C: { x: 3, y: includeNumbers ? 3 : 2 },
V: { x: 4, y: includeNumbers ? 3 : 2 },
B: { x: 5, y: includeNumbers ? 3 : 2 },
N: { x: 6, y: includeNumbers ? 3 : 2 },
M: { x: 7, y: includeNumbers ? 3 : 2 },
};
if (includeNumbers) {
const numberRow = {
'1': { x: 0, y: 0 },
'2': { x: 1, y: 0 },
'3': { x: 2, y: 0 },
'4': { x: 3, y: 0 },
'5': { x: 4, y: 0 },
'6': { x: 5, y: 0 },
'7': { x: 6, y: 0 },
'8': { x: 7, y: 0 },
'9': { x: 8, y: 0 },
'0': { x: 9, y: 0 },
};
return { ...numberRow, ...qwertyLayout };
}
return qwertyLayout;
};
export const generatePath = (points: Point[], curveType: CurveType): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
switch (curveType) {
case 'linear':
return generateLinearPath(points);
case 'simple-curve':
return generateSimpleCurvePath(points);
case 'quadratic-bezier':
return generateQuadraticBezierPath(points);
case 'cubic-bezier':
return generateCubicBezierPath(points);
case 'catmull-rom':
return generateCatmullRomPath(points);
default:
return generateLinearPath(points);
}
};
const generateLinearPath = (points: Point[]): string => {
if (points.length === 0) return '';
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
path += ` L ${points[i].x} ${points[i].y}`;
}
return path;
};
const generateSimpleCurvePath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const midX = (prev.x + curr.x) / 2;
const midY = (prev.y + curr.y) / 2;
if (i === 1) {
path += ` Q ${prev.x} ${prev.y} ${midX} ${midY}`;
} else {
path += ` T ${midX} ${midY}`;
}
}
const lastPoint = points[points.length - 1];
path += ` T ${lastPoint.x} ${lastPoint.y}`;
return path;
};
const generateQuadraticBezierPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const controlX = prev.x + (curr.x - prev.x) * 0.5;
const controlY = prev.y - Math.abs(curr.x - prev.x) * 0.3;
path += ` Q ${controlX} ${controlY} ${curr.x} ${curr.y}`;
}
return path;
};
const generateCubicBezierPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const cp1x = prev.x + dx * 0.25;
const cp1y = prev.y + dy * 0.25 - Math.abs(dx) * 0.2;
const cp2x = prev.x + dx * 0.75;
const cp2y = prev.y + dy * 0.75 - Math.abs(dx) * 0.2;
path += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${curr.x} ${curr.y}`;
}
return path;
};
const generateCatmullRomPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
if (points.length === 2) return generateLinearPath(points);
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const p0 = i === 1 ? points[0] : points[i - 2];
const p1 = points[i - 1];
const p2 = points[i];
const p3 = i === points.length - 1 ? points[i] : points[i + 1];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`;
}
return path;
};
@@ -0,0 +1,146 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
import { KeyboardLayout, StrokeStyle, generatePath, getKeyboardLayout } from './keyboard-utils';
export type SignaturePadKeyboardProps = {
className?: string;
onChange: (_value: string) => void;
};
export const SignaturePadKeyboard = ({ className, onChange }: SignaturePadKeyboardProps) => {
const [name, setName] = useState('');
const [currentKeyboardLayout] = useState<KeyboardLayout>(KeyboardLayout.QWERTY);
const curveType = 'linear';
const includeNumbers = false;
const strokeConfig = {
style: StrokeStyle.SOLID,
color: '#000000',
gradientStart: '#ff6b6b',
gradientEnd: '#4ecdc4',
width: 3,
};
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isInputFocused = document.activeElement === inputRef.current;
const isAnyInputFocused = document.activeElement?.tagName === 'INPUT';
if (!isInputFocused && !isAnyInputFocused) {
const regex = includeNumbers ? /^[a-zA-Z0-9]$/ : /^[a-zA-Z]$/;
if (regex.test(e.key) || e.key === 'Backspace') {
inputRef.current?.focus();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [includeNumbers]);
// Generate signature path
const signaturePath = useMemo(() => {
if (!name) return '';
const points = [];
const currentLayout = getKeyboardLayout(currentKeyboardLayout, includeNumbers);
for (const char of name.toUpperCase()) {
if (char in currentLayout) {
const { x, y } = currentLayout[char];
const yOffset = includeNumbers ? 100 : 40;
points.push({ x: x * 60 + 28, y: y * 60 + yOffset });
}
}
if (points.length === 0) return '';
return generatePath(points, curveType);
}, [name, currentKeyboardLayout, curveType, includeNumbers]);
// Update parent component when signature changes
useEffect(() => {
if (signaturePath && name) {
// Convert SVG to data URL for consistency with other signature types
const svgData = generateSVGDataURL(signaturePath);
onChange(svgData);
} else {
onChange('');
}
}, [signaturePath, name, onChange]);
const generateSVGDataURL = (path: string): string => {
const height = includeNumbers ? 260 : 200;
const gradients =
strokeConfig.style === StrokeStyle.GRADIENT
? `<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:${strokeConfig.gradientStart};stop-opacity:1" />
<stop offset="100%" style="stop-color:${strokeConfig.gradientEnd};stop-opacity:1" />
</linearGradient>`
: '';
const strokeColor =
strokeConfig.style === StrokeStyle.SOLID ? strokeConfig.color : 'url(#pathGradient)';
const svgContent = `<svg width="650" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>${gradients}</defs>
<path d="${path}" stroke="${strokeColor}" stroke-width="${strokeConfig.width}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center', className)}>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
className="sr-only"
autoFocus
/>
<div className="relative w-full max-w-lg">
<svg
className="pointer-events-none w-full"
viewBox="0 0 650 200"
preserveAspectRatio="xMidYMid meet"
style={{ height: '150px' }}
>
<defs>
{strokeConfig.style === StrokeStyle.GRADIENT && (
<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={strokeConfig.gradientStart} stopOpacity={1} />
<stop offset="100%" stopColor={strokeConfig.gradientEnd} stopOpacity={1} />
</linearGradient>
)}
</defs>
{signaturePath && (
<path
d={signaturePath}
stroke={
strokeConfig.style === StrokeStyle.SOLID ? strokeConfig.color : 'url(#pathGradient)'
}
strokeWidth={strokeConfig.width}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</div>
<div className="mt-4 w-full max-w-lg">
<div className="text-muted-foreground/70 font-mono text-xs">{name}</div>
</div>
</div>
);
};
@@ -1,7 +1,7 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { Keyboard, KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
@@ -10,6 +10,7 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { SignaturePadDraw } from './signature-pad-draw';
import { SignaturePadKeyboard } from './signature-pad-keyboard';
import { SignaturePadType } from './signature-pad-type';
import { SignaturePadUpload } from './signature-pad-upload';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
@@ -28,6 +29,7 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
keyboardSignatureEnabled?: boolean;
onValidityChange?: (isValid: boolean) => void;
};
@@ -39,10 +41,12 @@ export const SignaturePad = ({
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
keyboardSignatureEnabled = true,
}: SignaturePadProps) => {
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
const [keyboardSignature, setKeyboardSignature] = useState(isBase64Image(value) ? value : '');
/**
* This is cooked.
@@ -51,7 +55,7 @@ export const SignaturePad = ({
* the first enabled tab.
*/
const [tab, setTab] = useState(
((): 'draw' | 'text' | 'image' => {
((): 'draw' | 'text' | 'image' | 'keyboard' => {
// First passthrough to check to see if there's a signature for a given tab.
if (drawSignatureEnabled && drawSignature) {
return 'draw';
@@ -65,6 +69,10 @@ export const SignaturePad = ({
return 'image';
}
if (keyboardSignatureEnabled && keyboardSignature) {
return 'keyboard';
}
// Second passthrough to just select the first avaliable tab.
if (drawSignatureEnabled) {
return 'draw';
@@ -78,6 +86,10 @@ export const SignaturePad = ({
return 'image';
}
if (keyboardSignatureEnabled) {
return 'keyboard';
}
throw new Error('No signature enabled');
})(),
);
@@ -109,7 +121,16 @@ export const SignaturePad = ({
});
};
const onTabChange = (value: 'draw' | 'text' | 'image') => {
const onKeyboardSignatureChange = (value: string) => {
setKeyboardSignature(value);
onChange?.({
type: DocumentSignatureType.KEYBOARD,
value,
});
};
const onTabChange = (value: 'draw' | 'text' | 'image' | 'keyboard') => {
if (disabled) {
return;
}
@@ -126,10 +147,18 @@ export const SignaturePad = ({
.with('image', () => {
onImageSignatureChange(imageSignature);
})
.with('keyboard', () => {
onKeyboardSignatureChange(keyboardSignature);
})
.exhaustive();
};
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
if (
!drawSignatureEnabled &&
!typedSignatureEnabled &&
!uploadSignatureEnabled &&
!keyboardSignatureEnabled
) {
return null;
}
@@ -140,7 +169,7 @@ export const SignaturePad = ({
'pointer-events-none': disabled,
})}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image' | 'keyboard')}
>
<TabsList>
{drawSignatureEnabled && (
@@ -163,6 +192,13 @@ export const SignaturePad = ({
Upload
</TabsTrigger>
)}
{keyboardSignatureEnabled && (
<TabsTrigger value="keyboard">
<Keyboard className="mr-2 size-4" />
Keyboard
</TabsTrigger>
)}
</TabsList>
<TabsContent
@@ -194,6 +230,13 @@ export const SignaturePad = ({
>
<SignaturePadUpload value={imageSignature} onChange={onImageSignatureChange} />
</TabsContent>
<TabsContent
value="keyboard"
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
>
<SignaturePadKeyboard value={keyboardSignature} onChange={onKeyboardSignatureChange} />
</TabsContent>
</Tabs>
);
};