import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures'; import { AppError } from '@documenso/lib/errors/app-error'; import { Trans, useLingui } from '@lingui/react/macro'; import { motion } from 'framer-motion'; import { UploadCloudIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; import type { PointerEvent } from 'react'; import { useRef, useState } from 'react'; import { match } from 'ts-pattern'; import { cn } from '../../lib/utils'; import { useToast } from '../use-toast'; import { checkSignatureValidity } from './helper'; const MIN_ZOOM = 0.25; const MAX_ZOOM = 4; const ZOOM_STEP = 1.1; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const SignatureUploadErrorCode = { InvalidFileType: 'INVALID_FILE_TYPE', FileTooLarge: 'FILE_TOO_LARGE', InvalidImageDimensions: 'INVALID_IMAGE_DIMENSIONS', ImageLoadFailed: 'IMAGE_LOAD_FAILED', } as const; const loadImage = (file: File): Promise => { return new Promise((resolve, reject) => { if (!file.type.startsWith('image/')) { throw new AppError(SignatureUploadErrorCode.InvalidFileType); } if (file.size > 5 * 1024 * 1024) { throw new AppError(SignatureUploadErrorCode.FileTooLarge); } const img = new Image(); const objectUrl = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(objectUrl); // Vector images without explicit dimensions, such as an SVG with only a // viewBox, can report a zero width or height. Drawing them would produce // NaN geometry and silently export a blank signature. if (img.width === 0 || img.height === 0) { reject(new AppError(SignatureUploadErrorCode.InvalidImageDimensions)); return; } resolve(img); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new AppError(SignatureUploadErrorCode.ImageLoadFailed)); }; img.src = objectUrl; }); }; type DragState = { pointerId: number; startClientX: number; startClientY: number; startOffsetX: number; startOffsetY: number; clientToCanvasScale: number; }; export type SignaturePadUploadProps = { className?: string; value: string; onChange: (_signatureDataUrl: string) => void; }; export const SignaturePadUpload = ({ className, value, onChange, ...props }: SignaturePadUploadProps) => { const { t } = useLingui(); const { toast } = useToast(); const $el = useRef(null); const $fileInput = useRef(null); const $sourceImage = useRef(null); const $transform = useRef({ zoom: 1, offsetX: 0, offsetY: 0 }); const $drag = useRef(null); const $pendingFrame = useRef(null); /** * Incremented for every image load so stale async loads can be discarded. */ const $loadGeneration = useRef(0); const [hasImage, setHasImage] = useState(false); const [isDragging, setIsDragging] = useState(false); const [zoom, setZoom] = useState(1); const [isSignatureValid, setIsSignatureValid] = useState(null); /** * The scale at which the image fits entirely within the canvas while * preserving its aspect ratio. */ const getFitScale = (image: HTMLImageElement, canvas: HTMLCanvasElement) => Math.min(canvas.width / image.width, canvas.height / image.height); const draw = () => { const canvas = $el.current; const image = $sourceImage.current; if (!canvas || !image) { return; } const ctx = canvas.getContext('2d'); if (!ctx) { return; } const { zoom: currentZoom, offsetX, offsetY } = $transform.current; const scale = getFitScale(image, canvas) * currentZoom; const drawWidth = image.width * scale; const drawHeight = image.height * scale; const x = (canvas.width - drawWidth) / 2 + offsetX; const y = (canvas.height - drawHeight) / 2 + offsetY; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(image, x, y, drawWidth, drawHeight); }; const requestDraw = () => { if ($pendingFrame.current !== null) { return; } $pendingFrame.current = requestAnimationFrame(() => { $pendingFrame.current = null; draw(); }); }; /** * Export the canvas exactly as displayed, so the frame is the signature. * * The signature is only committed when it covers enough of the canvas to be * considered valid, otherwise the value is cleared so an invalid signature * cannot be submitted. */ const commitChange = () => { if (!$el.current) { return; } const isValid = checkSignatureValidity($el); setIsSignatureValid(isValid); onChange?.(isValid ? $el.current.toDataURL() : ''); }; const applyZoom = (nextZoom: number) => { if (!$sourceImage.current) { return; } const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM); $transform.current.zoom = clampedZoom; setZoom(clampedZoom); draw(); commitChange(); }; const onPointerDown = (event: PointerEvent) => { const canvas = $el.current; if (!canvas || !$sourceImage.current) { return; } // Only drag with the primary pointer and main button, otherwise a // right/middle click can arm a drag whose pointerup is swallowed by the // context menu, leaving the image glued to the cursor. if (!event.isPrimary || event.button !== 0) { return; } event.preventDefault(); canvas.setPointerCapture(event.pointerId); const rect = canvas.getBoundingClientRect(); $drag.current = { pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startOffsetX: $transform.current.offsetX, startOffsetY: $transform.current.offsetY, clientToCanvasScale: rect.width > 0 ? canvas.width / rect.width : SIGNATURE_CANVAS_DPI, }; setIsDragging(true); }; const onPointerMove = (event: PointerEvent) => { const drag = $drag.current; if (!drag || event.pointerId !== drag.pointerId) { return; } event.preventDefault(); $transform.current.offsetX = drag.startOffsetX + (event.clientX - drag.startClientX) * drag.clientToCanvasScale; $transform.current.offsetY = drag.startOffsetY + (event.clientY - drag.startClientY) * drag.clientToCanvasScale; requestDraw(); }; const onPointerEnd = (event: PointerEvent) => { const drag = $drag.current; if (!drag || event.pointerId !== drag.pointerId) { return; } const hasMoved = $transform.current.offsetX !== drag.startOffsetX || $transform.current.offsetY !== drag.startOffsetY; $drag.current = null; setIsDragging(false); if ($el.current?.hasPointerCapture(event.pointerId)) { $el.current.releasePointerCapture(event.pointerId); } if ($pendingFrame.current !== null) { cancelAnimationFrame($pendingFrame.current); $pendingFrame.current = null; } draw(); // Avoid emitting an identical signature when the pointer never moved, // such as a plain click on the canvas. if (hasMoved) { commitChange(); } }; const handleImageUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; // Allow re-selecting the same file to trigger another change event. event.target.value = ''; if (!file) { return; } const generation = ++$loadGeneration.current; let img: HTMLImageElement; try { img = await loadImage(file); } catch (err) { console.error(err); const error = AppError.parseError(err); const description = match(error.code) .with(SignatureUploadErrorCode.InvalidFileType, () => t`Please upload a valid image file.`) .with(SignatureUploadErrorCode.FileTooLarge, () => t`The image must be smaller than 5MB.`) .with( SignatureUploadErrorCode.InvalidImageDimensions, () => t`This image is invalid, please upload a valid image file.`, ) .otherwise(() => t`The image could not be loaded. Please try again.`); toast({ title: t`Unable to upload image`, description, variant: 'destructive', }); return; } // Discard the result if another image load started in the meantime. if (generation !== $loadGeneration.current) { return; } $sourceImage.current = img; $transform.current = { zoom: 1, offsetX: 0, offsetY: 0 }; setHasImage(true); setZoom(1); draw(); commitChange(); }; 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 generation = ++$loadGeneration.current; const img = new Image(); img.onload = () => { // Discard the result if another image load started in the meantime. if (generation !== $loadGeneration.current) { return; } // Display the existing signature aspect-fitted and centered, ready to // be adjusted further with zoom and drag. This is display-only and // intentionally does not call onChange. $sourceImage.current = img; $transform.current = { zoom: 1, offsetX: 0, offsetY: 0 }; setHasImage(true); setZoom(1); draw(); }; img.onerror = () => { console.error(new AppError(SignatureUploadErrorCode.ImageLoadFailed)); }; img.src = value; } return () => { if ($pendingFrame.current !== null) { cancelAnimationFrame($pendingFrame.current); $pendingFrame.current = null; } }; }); return (
{hasImage && (
)} {hasImage && (
)} {isSignatureValid === false && (
Signature is too small
)} {!hasImage && ( $fileInput.current?.click()} >
Upload Signature
)}
); };