Skip to content
This repository was archived by the owner on Jan 30, 2026. It is now read-only.

Commit ac8a6d2

Browse files
feat: resize signature pad and trim signature whitespaces
1 parent 0d25c3e commit ac8a6d2

1 file changed

Lines changed: 116 additions & 3 deletions

File tree

demo/src/CustomSignature.tsx

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,101 @@ import React, { useEffect, useRef, useState } from 'react';
22
import SignaturePad from 'signature_pad';
33
import type { FieldComponentProps } from '@superdoc-dev/esign';
44

5+
// Trim whitespace around strokes by tightening the SVG viewBox.
6+
const cropSVG = (svgText: string): string => {
7+
const container = document.createElement('div');
8+
container.setAttribute('style', 'visibility: hidden; position: absolute; left: -9999px;');
9+
document.body.appendChild(container);
10+
11+
try {
12+
container.innerHTML = svgText;
13+
const svgElement = container.getElementsByTagName('svg')[0];
14+
if (!svgElement) return svgText;
15+
16+
const bbox = svgElement.getBBox();
17+
if (bbox.width === 0 || bbox.height === 0) return svgText;
18+
19+
const padding = 5;
20+
const viewBox = [
21+
bbox.x - padding,
22+
bbox.y - padding,
23+
bbox.width + padding * 2,
24+
bbox.height + padding * 2,
25+
].join(' ');
26+
svgElement.setAttribute('viewBox', viewBox);
27+
svgElement.setAttribute('width', String(Math.ceil(bbox.width + padding * 2)));
28+
svgElement.setAttribute('height', String(Math.ceil(bbox.height + padding * 2)));
29+
30+
return svgElement.outerHTML;
31+
} finally {
32+
container.remove();
33+
}
34+
};
35+
36+
// Rasterize a cropped SVG into a PNG data URL.
37+
const svgToPngDataUrl = (svgText: string): Promise<string> =>
38+
new Promise((resolve, reject) => {
39+
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`;
40+
const img = new Image();
41+
img.onload = () => {
42+
const canvas = document.createElement('canvas');
43+
canvas.width = img.width;
44+
canvas.height = img.height;
45+
const ctx = canvas.getContext('2d');
46+
if (!ctx) {
47+
reject(new Error('Canvas context not available'));
48+
return;
49+
}
50+
ctx.drawImage(img, 0, 0);
51+
resolve(canvas.toDataURL('image/png'));
52+
};
53+
img.onerror = () => reject(new Error('Failed to load SVG for rasterization'));
54+
img.src = svgDataUrl;
55+
});
56+
557
const CustomSignature: React.FC<FieldComponentProps> = ({ value, onChange, isDisabled, label }) => {
658
const [mode, setMode] = useState<'type' | 'draw'>('type');
759
const canvasRef = useRef<HTMLCanvasElement>(null);
860
const signaturePadRef = useRef<SignaturePad | null>(null);
61+
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
62+
const latestDataUrlRef = useRef<string | null>(null);
63+
const conversionIdRef = useRef(0);
64+
65+
const clearCommitTimer = () => {
66+
if (commitTimerRef.current) {
67+
clearTimeout(commitTimerRef.current);
68+
commitTimerRef.current = null;
69+
}
70+
};
71+
72+
// Debounced export to avoid re-rendering during active drawing.
73+
const commitSignature = () => {
74+
if (!signaturePadRef.current) return;
75+
if (signaturePadRef.current.isEmpty()) {
76+
latestDataUrlRef.current = null;
77+
onChange('');
78+
return;
79+
}
80+
81+
const svgText = signaturePadRef.current.toSVG();
82+
const croppedSvg = cropSVG(svgText);
83+
const conversionId = ++conversionIdRef.current;
84+
85+
svgToPngDataUrl(croppedSvg)
86+
.then((dataUrl) => {
87+
if (conversionIdRef.current !== conversionId) return;
88+
latestDataUrlRef.current = dataUrl;
89+
onChange(dataUrl);
90+
})
91+
.catch((error) => {
92+
console.error('Failed to convert signature to PNG:', error);
93+
});
94+
};
995

1096
const switchMode = (newMode: 'type' | 'draw') => {
97+
clearCommitTimer();
98+
latestDataUrlRef.current = null;
99+
conversionIdRef.current += 1;
11100
setMode(newMode);
12101
onChange('');
13102
if (newMode === 'draw' && signaturePadRef.current) {
@@ -18,13 +107,32 @@ const CustomSignature: React.FC<FieldComponentProps> = ({ value, onChange, isDis
18107
const clearCanvas = () => {
19108
if (signaturePadRef.current) {
20109
signaturePadRef.current.clear();
110+
clearCommitTimer();
111+
latestDataUrlRef.current = null;
112+
conversionIdRef.current += 1;
21113
onChange('');
22114
}
23115
};
24116

25117
useEffect(() => {
26118
if (!canvasRef.current || mode !== 'draw') return;
27119

120+
const canvas = canvasRef.current;
121+
// Match canvas pixels to display size for correct pointer mapping.
122+
const resizeCanvas = () => {
123+
const rect = canvas.getBoundingClientRect();
124+
const ratio = Math.max(window.devicePixelRatio || 1, 1);
125+
canvas.width = Math.floor(rect.width * ratio);
126+
canvas.height = Math.floor(rect.height * ratio);
127+
const ctx = canvas.getContext('2d');
128+
if (ctx) {
129+
ctx.scale(ratio, ratio);
130+
}
131+
signaturePadRef.current?.clear();
132+
};
133+
134+
resizeCanvas();
135+
28136
signaturePadRef.current = new SignaturePad(canvasRef.current, {
29137
backgroundColor: 'rgb(255, 255, 255)',
30138
penColor: 'rgb(0, 0, 0)',
@@ -36,14 +144,21 @@ const CustomSignature: React.FC<FieldComponentProps> = ({ value, onChange, isDis
36144

37145
signaturePadRef.current.addEventListener('endStroke', () => {
38146
if (signaturePadRef.current) {
39-
onChange(signaturePadRef.current.toDataURL());
147+
clearCommitTimer();
148+
commitTimerRef.current = setTimeout(() => {
149+
commitSignature();
150+
}, 1000);
40151
}
41152
});
42153

154+
window.addEventListener('resize', resizeCanvas);
155+
43156
return () => {
44157
if (signaturePadRef.current) {
45158
signaturePadRef.current.off();
46159
}
160+
clearCommitTimer();
161+
window.removeEventListener('resize', resizeCanvas);
47162
};
48163
}, [mode, isDisabled, onChange]);
49164

@@ -109,8 +224,6 @@ const CustomSignature: React.FC<FieldComponentProps> = ({ value, onChange, isDis
109224
<div>
110225
<canvas
111226
ref={canvasRef}
112-
width={500}
113-
height={150}
114227
style={{
115228
border: '1px solid #d1d5db',
116229
borderRadius: '8px',

0 commit comments

Comments
 (0)