'use client'; import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; import { Trans } from '@lingui/macro'; import { Undo2, Upload } 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 { Input } from '@documenso/ui/primitives/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@documenso/ui/primitives/select'; import { cn } from '../../lib/utils'; import { getSvgPathFromStroke } from './helper'; import { Point } from './point'; const fontCaveat = Caveat({ weight: ['500'], subsets: ['latin'], display: 'swap', variable: '--font-caveat', }); const DPI = 2; const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,'); const loadImage = async (file: File | undefined): Promise => { if (!file) { throw new Error('No file selected'); } if (!file.type.startsWith('image/')) { throw new Error('Invalid file type'); } if (file.size > 5 * 1024 * 1024) { throw new Error('Image size should be less than 5MB'); } return new Promise((resolve, reject) => { const img = new Image(); const objectUrl = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(objectUrl); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error('Failed to load image')); }; img.src = objectUrl; }); }; const loadImageOntoCanvas = ( image: HTMLImageElement, canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, ): ImageData => { const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height); const x = (canvas.width - image.width * scale) / 2; const y = (canvas.height - image.height * scale) / 2; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(image, x, y, image.width * scale, image.height * scale); ctx.restore(); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); return imageData; }; export type SignaturePadProps = Omit, 'onChange'> & { onChange?: (_signatureDataUrl: string | null) => void; containerClassName?: string; disabled?: boolean; allowTypedSignature?: boolean; defaultValue?: string; }; export const SignaturePad = ({ className, containerClassName, defaultValue, onChange, disabled = false, allowTypedSignature, ...props }: SignaturePadProps) => { const $el = useRef(null); const $imageData = useRef(null); const $fileInput = useRef(null); const [isPressed, setIsPressed] = useState(false); const [lines, setLines] = useState([]); const [currentLine, setCurrentLine] = useState([]); const [selectedColor, setSelectedColor] = useState('black'); const [typedSignature, setTypedSignature] = useState( defaultValue && !isBase64Image(defaultValue) ? defaultValue : '', ); 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); 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]); }; const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => { if (event.cancelable) { event.preventDefault(); } if (!isPressed) { return; } const point = Point.fromEvent(event, DPI, $el.current); if (point.distanceTo(currentLine[currentLine.length - 1]) > 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, 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); }); 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(); } 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([]); }; 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 = String(fontCaveat.style.fontFamily); ctx.clearRect(0, 0, canvasWidth, canvasHeight); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = selectedColor; // Calculate the desired width (25ch) const desiredWidth = canvasWidth * 0.85; // 85% of canvas width // Start with a base font size let fontSize = 18; ctx.font = `${fontSize}px ${fontFamily}`; // Measure 10 characters and calculate scale factor const characterWidth = ctx.measureText('m'.repeat(10)).width; const scaleFactor = desiredWidth / characterWidth; // Apply scale factor to font size fontSize = fontSize * scaleFactor; // Adjust font size if it exceeds canvas width ctx.font = `${fontSize}px ${fontFamily}`; const textWidth = ctx.measureText(typedSignature).width; if (textWidth > desiredWidth) { fontSize = fontSize * (desiredWidth / textWidth); } // Set final font and render text ctx.font = `${fontSize}px ${fontFamily}`; ctx.fillText(typedSignature, canvasWidth / 2, canvasHeight / 2); } } }; const handleTypedSignatureChange = (event: React.ChangeEvent) => { const newValue = event.target.value; setTypedSignature(newValue); if (newValue.trim() !== '') { onChange?.(newValue); } else { onChange?.(null); } }; const handleImageUpload = async (event: React.ChangeEvent) => { try { const img = await loadImage(event.target.files?.[0]); if (!$el.current) return; const ctx = $el.current.getContext('2d'); if (!ctx) return; $imageData.current = loadImageOntoCanvas(img, $el.current, ctx); onChange?.($el.current.toDataURL()); 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; } }); return (
onMouseMove(event)} onPointerDown={(event) => onMouseDown(event)} onPointerUp={(event) => onMouseUp(event)} onPointerLeave={(event) => onMouseLeave(event)} onPointerEnter={(event) => onMouseEnter(event)} {...props} /> {allowTypedSignature && (
0 || typedSignature.length > 0, })} >
)}
$fileInput.current?.click()} > Upload Signature
{(lines.length > 0 || typedSignature.length > 0) && (
)}
); };