From 357017314da2c2d603794b4b147970360091620b Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 19 Aug 2025 08:48:04 +0000 Subject: [PATCH] feat: add digitized signatures --- .../document-signing-signature-field.tsx | 3 + packages/lib/constants/document.ts | 7 + packages/lib/utils/teams.ts | 14 +- .../20250819002115_add_keyboard/migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../signature-pad/keyboard-utils.ts | 207 ++++++++++++++++++ .../signature-pad/signature-pad-keyboard.tsx | 146 ++++++++++++ .../signature-pad/signature-pad.tsx | 53 ++++- 8 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 packages/prisma/migrations/20250819002115_add_keyboard/migration.sql create mode 100644 packages/ui/primitives/signature-pad/keyboard-utils.ts create mode 100644 packages/ui/primitives/signature-pad/signature-pad-keyboard.tsx diff --git a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index 8c80925d7..3de911857 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -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} /> diff --git a/packages/lib/constants/document.ts b/packages/lib/constants/document.ts index b0ce41584..9bbcc5e27 100644 --- a/packages/lib/constants/document.ts +++ b/packages/lib/constants/document.ts @@ -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; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index 3665baf33..ffb7b4989 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -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 => { + const qwertyLayout: Record = { + 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; +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad-keyboard.tsx b/packages/ui/primitives/signature-pad/signature-pad-keyboard.tsx new file mode 100644 index 000000000..1add59a12 --- /dev/null +++ b/packages/ui/primitives/signature-pad/signature-pad-keyboard.tsx @@ -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.QWERTY); + + const curveType = 'linear'; + const includeNumbers = false; + const strokeConfig = { + style: StrokeStyle.SOLID, + color: '#000000', + gradientStart: '#ff6b6b', + gradientEnd: '#4ecdc4', + width: 3, + }; + + const inputRef = useRef(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 + ? ` + + + ` + : ''; + const strokeColor = + strokeConfig.style === StrokeStyle.SOLID ? strokeConfig.color : 'url(#pathGradient)'; + + const svgContent = ` + ${gradients} + + `; + + return `data:image/svg+xml;base64,${btoa(svgContent)}`; + }; + + return ( +
+ setName(e.target.value)} + className="sr-only" + autoFocus + /> + +
+ + + {strokeConfig.style === StrokeStyle.GRADIENT && ( + + + + + )} + + + {signaturePath && ( + + )} + +
+ +
+
{name}
+
+
+ ); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 20fbb1aad..31b7c3d87 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -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, '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')} > {drawSignatureEnabled && ( @@ -163,6 +192,13 @@ export const SignaturePad = ({ Upload )} + + {keyboardSignatureEnabled && ( + + + Keyboard + + )} + + + + ); };