Files
frontend/src/components/SignaturePad.tsx
T
Frudrax Cheng 1d944b0fd3 Replace phone verification with canvas signature on confirm page
Adds a canvas-based SignaturePad component used on the customer
confirm page. Authorize now requires a non-empty signature; reject
opens a required reason modal. The archived signature is shown to
the customer after confirming and on the admin detail page.

Also fixes the confirm page being clipped at the top when its
content exceeds the viewport: the public layout used
height:100vh + overflow:hidden which cropped the centered card.
Switched to min-height:100vh so tall content can scroll naturally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:02:18 +08:00

131 lines
3.7 KiB
TypeScript

import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
export interface SignaturePadHandle {
clear: () => void;
isEmpty: () => boolean;
getDataURL: () => string;
}
interface SignaturePadProps {
width?: number;
height?: number;
onChange?: (dataUrl: string) => void;
}
const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(
{ width = 480, height = 180, onChange },
ref,
) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef(false);
const emptyRef = useRef(true);
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
const getContext = () => canvasRef.current?.getContext('2d') ?? null;
const resetCanvas = () => {
const canvas = canvasRef.current;
const ctx = getContext();
if (!canvas || !ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#111827';
emptyRef.current = true;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// 高 DPI 适配
const ratio = window.devicePixelRatio || 1;
canvas.width = width * ratio;
canvas.height = height * ratio;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
if (ctx) ctx.scale(ratio, ratio);
resetCanvas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [width, height]);
const pointFromEvent = (
e: React.PointerEvent<HTMLCanvasElement>,
): { x: number; y: number } | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
e.preventDefault();
const ctx = getContext();
const pt = pointFromEvent(e);
if (!ctx || !pt) return;
drawingRef.current = true;
lastPointRef.current = pt;
canvasRef.current?.setPointerCapture(e.pointerId);
// 画一个点(防止只点一下没移动看不到痕迹)
ctx.beginPath();
ctx.arc(pt.x, pt.y, 1.2, 0, Math.PI * 2);
ctx.fillStyle = '#111827';
ctx.fill();
emptyRef.current = false;
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
e.preventDefault();
const ctx = getContext();
const pt = pointFromEvent(e);
const last = lastPointRef.current;
if (!ctx || !pt || !last) return;
ctx.beginPath();
ctx.moveTo(last.x, last.y);
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
lastPointRef.current = pt;
emptyRef.current = false;
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
drawingRef.current = false;
lastPointRef.current = null;
canvasRef.current?.releasePointerCapture(e.pointerId);
if (onChange && canvasRef.current) {
onChange(canvasRef.current.toDataURL('image/png'));
}
};
useImperativeHandle(ref, () => ({
clear: () => {
resetCanvas();
onChange?.('');
},
isEmpty: () => emptyRef.current,
getDataURL: () => (canvasRef.current ? canvasRef.current.toDataURL('image/png') : ''),
}));
return (
<canvas
ref={canvasRef}
className="signature-pad-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerLeave={handlePointerUp}
style={{ touchAction: 'none' }}
/>
);
});
export default SignaturePad;