'use client'; import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Undo2 } from 'lucide-react'; import type { StrokeOptions } from 'perfect-freehand'; import { getStroke } from 'perfect-freehand'; import { cn } from '../../lib/utils'; import { getSvgPathFromStroke } from './helper'; import { Point } from './point'; const DPI = 2; export type SignaturePadProps = Omit, 'onChange'> & { onChange?: (_signatureDataUrl: string | null) => void; containerClassName?: string; }; export const SignaturePad = ({ className, containerClassName, defaultValue, onChange, ...props }: SignaturePadProps) => { const $el = useRef(null); const defaultImageRef = useRef(null); const [isPressed, setIsPressed] = useState(false); const [lines, setLines] = useState([]); const [currentLine, setCurrentLine] = useState([]); 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, 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'; 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'; 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); defaultImageRef.current = null; } onChange?.(null); setLines([]); setCurrentLine([]); }; const onUndoClick = () => { if (lines.length === 0) { return; } const newLines = [...lines]; newLines.pop(); // Remove the last line setLines(newLines); // Clear the canvas if ($el.current) { const ctx = $el.current.getContext('2d'); const { width, height } = $el.current; ctx?.clearRect(0, 0, $el.current.width, $el.current.height); if (typeof defaultValue === 'string' && defaultImageRef.current) { ctx?.putImageData(defaultImageRef.current, 0, 0); } newLines.forEach((line) => { const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); ctx?.fill(pathData); }); } }; useEffect(() => { if ($el.current) { $el.current.width = $el.current.clientWidth * DPI; $el.current.height = $el.current.clientHeight * DPI; } }, []); useEffect(() => { 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; defaultImageRef.current = defaultImageData; }; img.src = defaultValue; } }, [defaultValue]); return ( onMouseMove(event)} onPointerDown={(event) => onMouseDown(event)} onPointerUp={(event) => onMouseUp(event)} onPointerLeave={(event) => onMouseLeave(event)} onPointerEnter={(event) => onMouseEnter(event)} {...props} /> onClearClick()} > Clear Signature {lines.length > 0 && ( onUndoClick()} > Undo )} ); };