@@ -2,12 +2,101 @@ import React, { useEffect, useRef, useState } from 'react';
22import SignaturePad from 'signature_pad' ;
33import 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+
557const 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