"use client"; import { useEffect, useId, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useReducedMotion } from "motion/react"; type SignaturePadProps = { /** Printed on the paper and read out by screen readers. */ label?: string; /** Form field name. Sends the signature as a PNG data URL. */ name?: string; /** Pen colour. */ ink?: string; /** Called with the PNG data URL, or "" once cleared. */ onChange?: (signature: string) => void; /** Set the width here. Everything scales with it. */ className?: string; }; type Point = { x: number; y: number; w: number }; // Canvas pixels: the top of a 520 x 320 sheet, at double size. const W = 1040; const H = 460; const THIN = 2.6; const THICK = 8; const SCRIPT = '"Segoe Script", "Snell Roundhand", "Brush Script MT", cursive'; const midpoint = (a: Point, b: Point) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); /* One curve from the midpoint of a-b to the midpoint of b-c, at b's width. */ function curve(ctx: CanvasRenderingContext2D, a: Point, b: Point, c: Point) { const from = midpoint(a, b); const to = midpoint(b, c); ctx.lineWidth = b.w; ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.quadraticCurveTo(b.x, b.y, to.x, to.y); ctx.stroke(); } function dot(ctx: CanvasRenderingContext2D, p: Point) { ctx.beginPath(); ctx.arc(p.x, p.y, p.w / 2, 0, Math.PI * 2); ctx.fill(); } /* SignaturePad: a sheet of bond paper with a revenue stamp. Ink thins as the pen speeds up and goes on wet, then dries to its true blue; sign across the stamp, or type a name for keyboard use. */ export function SignaturePad({ label = "Signature of the party", name, ink = "#1c3f9e", onChange, className = "w-full max-w-lg" }: SignaturePadProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const canvas = useRef(null); const nib = useRef(null); const strokes = useRef([]); const drying = useRef(0); const [typed, setTyped] = useState(""); const [count, setCount] = useState(0); const [signature, setSignature] = useState(""); const [wet, setWet] = useState(false); const context = () => { const ctx = canvas.current?.getContext("2d"); if (!ctx) return null; ctx.strokeStyle = ink; ctx.fillStyle = ink; ctx.lineCap = "round"; ctx.lineJoin = "round"; return ctx; }; const repaint = (text: string) => { const ctx = context(); if (!ctx) return; ctx.clearRect(0, 0, W, H); for (const stroke of strokes.current) { if (stroke.length < 3) dot(ctx, stroke[0]); for (let i = 2; i < stroke.length; i++) curve(ctx, stroke[i - 2], stroke[i - 1], stroke[i]); } if (text) { ctx.font = `104px ${SCRIPT}`; const size = Math.min(104, (104 * 880) / ctx.measureText(text).width); ctx.font = `${size}px ${SCRIPT}`; ctx.fillText(text, 80, 330, 880); } }; const commit = (text: string) => { const empty = !text && strokes.current.length === 0; const data = empty ? "" : (canvas.current?.toDataURL("image/png") ?? ""); setCount(strokes.current.length); setSignature(data); onChange?.(data); }; const dry = () => { setWet(true); clearTimeout(drying.current); drying.current = window.setTimeout(() => setWet(false), 900); }; useEffect(() => () => clearTimeout(drying.current), []); const place = (e: PointerEvent | ReactPointerEvent) => { const box = canvas.current!.getBoundingClientRect(); return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H }; }; const moveNib = (e: ReactPointerEvent, show: boolean) => { const box = e.currentTarget.getBoundingClientRect(); if (!nib.current) return; nib.current.style.opacity = show && e.pointerType === "mouse" ? "1" : "0"; nib.current.style.left = `${((e.clientX - box.left) / box.width) * 100}%`; nib.current.style.top = `${((e.clientY - box.top) / box.height) * 100}%`; }; const down = (e: ReactPointerEvent) => { if (e.button !== 0) return; e.currentTarget.setPointerCapture(e.pointerId); if (typed) { setTyped(""); repaint(""); } const start = place(e); const stroke: Point[] = [{ ...start, w: THICK * 0.7 }]; strokes.current.push(stroke); const ctx = context(); if (ctx) dot(ctx, stroke[0]); dry(); }; const move = (e: ReactPointerEvent) => { moveNib(e, true); if (!e.currentTarget.hasPointerCapture(e.pointerId)) return; const stroke = strokes.current[strokes.current.length - 1]; const ctx = context(); if (!stroke || !ctx) return; for (const event of e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent]) { const { x, y } = place(event); const last = stroke[stroke.length - 1]; const speed = Math.hypot(x - last.x, y - last.y); if (speed < 1.5) continue; const target = Math.max(THIN, THICK - speed * 0.22); stroke.push({ x, y, w: last.w + (target - last.w) * 0.3 }); if (stroke.length > 2) curve(ctx, stroke[stroke.length - 3], stroke[stroke.length - 2], stroke[stroke.length - 1]); } dry(); }; const up = () => { commit(""); }; const undo = () => { strokes.current.pop(); repaint(""); setTyped(""); commit(""); }; const clear = () => { strokes.current = []; repaint(""); setTyped(""); commit(""); }; const type = (text: string) => { strokes.current = []; setTyped(text); repaint(text); commit(text); dry(); }; const small = "rounded border border-[#8b7f66] px-[1.8cqw] py-[0.8cqw] text-[3cqw] text-[#4d4433] transition-colors hover:bg-[#e6dcc2] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#1c3f9e] disabled:opacity-40 disabled:hover:bg-transparent"; return (
REVENUE ₹1 ONE RUPEE {"IN WITNESS WHEREOF"} {label} moveNib(e, false)} style={{ filter: wet ? "brightness(0.5) saturate(1.3)" : "none", transition: reduce || wet ? "none" : "filter 1.4s ease-out" }} className="absolute inset-x-0 top-0 aspect-[1040/460] w-full cursor-crosshair touch-none [@media(hover:hover)]:cursor-none" />
type(e.target.value)} placeholder="or type your name" aria-label="Type your name to sign" autoComplete="name" maxLength={40} className="min-w-0 flex-1 rounded border-b border-[#8b7f66] bg-transparent px-[1cqw] py-[0.6cqw] text-[4.2cqw] text-[#1c3f9e] placeholder:text-[#7c7057] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#1c3f9e]" />
{name && }

{signature ? "Signature added" : "No signature yet"}

); }