"use client"; import { BadgeCheckIcon, EraserIcon, PenLineIcon, Undo2Icon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type PointerEvent, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; type Point = { x: number; y: number; w: number }; type Stroke = Point[]; const MIN_W = 1.1; const MAX_W = 3.4; function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke, from = 0) { if (stroke.length === 1) { const p = stroke[0]; ctx.beginPath(); ctx.arc(p.x, p.y, p.w / 2, 0, Math.PI * 2); ctx.fill(); return; } for (let i = Math.max(1, from); i < stroke.length; i++) { const a = stroke[i - 1]; const b = stroke[i]; const before = stroke[i - 2] ?? a; const start = { x: (before.x + a.x) / 2, y: (before.y + a.y) / 2 }; const end = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; ctx.beginPath(); ctx.moveTo(i === 1 ? a.x : start.x, i === 1 ? a.y : start.y); ctx.quadraticCurveTo(a.x, a.y, end.x, end.y); ctx.lineWidth = (a.w + b.w) / 2; ctx.stroke(); } } export interface SignaturePadProps { signer: string; label?: string; onSign?: (png: string, signedAt: Date) => void; onClear?: () => void; className?: string; } export function SignaturePad({ signer, label = "Signature", onSign, onClear, className }: SignaturePadProps) { const canvasRef = useRef(null); const boxRef = useRef(null); const strokes = useRef([]); const typedInk = useRef<{ text: string; font: string; x: number; y: number } | null>(null); const typedRef = useRef(null); const live = useRef<{ stroke: Stroke; t: number; id: number } | null>(null); const [count, setCount] = useState(0); const [signedAt, setSignedAt] = useState(null); const [mode, setMode] = useState<"draw" | "type">("draw"); const [typed, setTyped] = useState(signer); const reduce = useReducedMotion(); const id = useId(); const ctx = useCallback(() => { const canvas = canvasRef.current; const c = canvas?.getContext("2d"); if (!canvas || !c) return null; const color = getComputedStyle(canvas).color; c.strokeStyle = color; c.fillStyle = color; c.lineCap = "round"; c.lineJoin = "round"; return c; }, []); const redraw = useCallback(() => { const canvas = canvasRef.current; const c = ctx(); if (!canvas || !c) return; c.clearRect(0, 0, canvas.width, canvas.height); for (const s of strokes.current) drawStroke(c, s); if (typedInk.current) { c.font = typedInk.current.font; c.textBaseline = "middle"; c.fillText(typedInk.current.text, typedInk.current.x, typedInk.current.y); } }, [ctx]); useLayoutEffect(() => { const canvas = canvasRef.current; const box = boxRef.current; if (!canvas || !box) return; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 3); canvas.width = Math.round(box.clientWidth * dpr); canvas.height = Math.round(box.clientHeight * dpr); canvas.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); redraw(); }; resize(); const sizes = new ResizeObserver(resize); sizes.observe(box); const theme = new MutationObserver(redraw); theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] }); return () => { sizes.disconnect(); theme.disconnect(); }; }, [redraw]); const onSignRef = useRef(onSign); onSignRef.current = onSign; useEffect(() => { redraw(); if (!signedAt) return; const png = canvasRef.current?.toDataURL("image/png"); if (png) onSignRef.current?.(png, signedAt); }, [signedAt, redraw]); const point = ( canvas: HTMLCanvasElement, e: { clientX: number; clientY: number; pointerType: string; pressure: number }, prev?: Point, dt = 16, ): Point => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; let target: number; if (e.pointerType === "pen" && e.pressure > 0) { target = MIN_W + (MAX_W - MIN_W) * Math.min(e.pressure * 1.4, 1); } else { const speed = prev ? Math.hypot(x - prev.x, y - prev.y) / Math.max(dt, 1) : 0; target = Math.max(MIN_W, MAX_W - speed * 1.5); } return { x, y, w: prev ? prev.w * 0.65 + target * 0.35 : target * 0.8 }; }; const onPointerDown = (event: PointerEvent) => { if (signedAt || event.button !== 0) return; event.currentTarget.setPointerCapture(event.pointerId); const stroke: Stroke = [point(event.currentTarget, event)]; live.current = { stroke, t: performance.now(), id: event.pointerId }; strokes.current.push(stroke); const c = ctx(); if (c) drawStroke(c, stroke); }; const onPointerMove = (event: PointerEvent) => { const l = live.current; if (!l || l.id !== event.pointerId) return; const events = event.nativeEvent.getCoalescedEvents?.() ?? [event.nativeEvent]; const c = ctx(); for (const e of events) { const now = performance.now(); const prev = l.stroke[l.stroke.length - 1]; const p = point(event.currentTarget, e, prev, now - l.t); if (Math.hypot(p.x - prev.x, p.y - prev.y) < 1.2) continue; l.t = now; l.stroke.push(p); if (c) drawStroke(c, l.stroke, l.stroke.length - 1); } }; const onPointerUp = () => { if (!live.current) return; live.current = null; setCount(strokes.current.length); }; const undo = () => { strokes.current.pop(); setCount(strokes.current.length); redraw(); }; const clear = () => { strokes.current = []; typedInk.current = null; setMode("draw"); setCount(0); setSignedAt(null); redraw(); onClear?.(); }; const sign = () => { if (mode === "type") { strokes.current = []; const input = typedRef.current; const box = boxRef.current?.getBoundingClientRect(); if (input && box) { const style = getComputedStyle(input); const r = input.getBoundingClientRect(); typedInk.current = { text: typed.trim(), font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, x: r.left - box.left + Number.parseFloat(style.paddingLeft) - 1, y: r.top - box.top + r.height / 2 + 2, }; } } setSignedAt(new Date()); }; const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" && count && !signedAt) { event.preventDefault(); undo(); } }; const empty = mode === "draw" ? count === 0 : !typed.trim(); const when = signedAt ? new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(signedAt) : ""; return (
{label} {!signedAt && ( )}
×
{empty && mode === "draw" && ( Sign with a finger, mouse or pen )} {mode === "type" && !signedAt && ( setTyped(event.target.value)} placeholder="Your full name" ref={typedRef} value={typed} /> )} {signedAt && ( Signed )}
{signedAt ? (

Signed by {signer} · {when}

) : (
)} {signedAt ? ( ) : ( )}
); }