前言:H5时常需要实现给C端用户签名的功能,包括但不限于实现签名、重签、手机自动旋转样式保持等功能,以下是基于Taro框架开发的H5页面实现,非 Taro 框架的普通 H5 将 View 标签换成 div 即可
。
一、用到的技术库
- 签字库:
react-signature-canvas
- 主流React Hooks 库:
ahooks
二、组件具体实现
解决H5横竖屏样式问题,主要还是通过CSS媒体查询实现两套样式处理横屏和竖屏的样式
index.tsx
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import SignatureCanvas from 'react-signature-canvas';
import { useSize } from 'ahooks';
import { View } from '@tarojs/components';
import { rotateImg, Toast, throttle } from './utils';
import './index.less';
interface IProps {
visible: boolean;
setVisible?: (e: boolean) => void;
signText?: string;
onChange?: (e?) => void;
onSure: (e?) => void;
onCancel?: (e?) => void;
onResign?: (e?) => void;
}
const SignatureBoard = (props: IProps) => {
const {
visible,
setVisible,
signText = '请在此空白处签下您的姓名',
onChange,
onSure,
onCancel,
onResign
} = props;
const [signTip, setSignTip] = useState(signText);
const sigCanvasRef = useRef<SignatureCanvas | null>(null);
const canvasContainer = useRef<HTMLElement>(null);
const compContainer = useRef<HTMLElement>(null);
const compSize = useSize(compContainer);
const canvasSize = useSize(canvasContainer);
const [isLandscape, setIsLandscape] = useState<boolean>(false);
const tipText = useMemo(() => {
return signTip?.split('') || [];
}, [signTip]);
const clearSign = useCallback(() => {
setSignTip(signText);
sigCanvasRef?.current?.clear();
onResign?.();
}, [onResign, signText]);
const cancelSign = useCallback(() => {
setSignTip(signText);
sigCanvasRef?.current?.clear();
onCancel?.();
setVisible?.(false);
}, [onCancel, setVisible, signText]);
const sureSign = useCallback(() => {
const pointGroupArray = sigCanvasRef?.current?.toData();
if (pointGroupArray.flat().length < 30) {
Toast({ title: '请使用正楷字签名', rotate: isLandscape ? 0 : 90 });
return;
}
if (isLandscape) {
onSure?.(sigCanvasRef.current.toDataURL());
} else {
rotateImg(sigCanvasRef?.current?.toDataURL(), result => onSure?.(result), 270);
}
setVisible?.(false);
}, [isLandscape, onSure, setVisible]);
useEffect(() => {
if ((compSize?.width ?? 0) > (compSize?.height ?? 1)) {
setIsLandscape(true);
clearSign();
} else {
setIsLandscape(false);
clearSign();
}
}, [clearSign, compSize?.height, compSize?.width]);
if (!visible) return null;
return (
<View ref={compContainer} className='signature-board-comp' onClick={e => e.stopPropagation()}>
<View className='sign-board-btns'>
<View className='board-btn' onClick={cancelSign}>
<View className='board-btn-text'>取消</View>
</View>
<View className='board-btn' onClick={clearSign}>
<View className='board-btn-text'>重签</View>
</View>
<View className='board-btn confirm-btn' onClick={throttle(sureSign, 2000)}>
<View className='board-btn-text'>确定</View>
</View>
</View>
<View className='sign-board' ref={canvasContainer}>
<SignatureCanvas
penColor='#000'
minWidth={1}
maxWidth={1}
canvasProps={{
id: 'sigCanvas',
width: canvasSize?.width,
height: canvasSize?.height,
className: 'sigCanvas'
}}
ref={sigCanvasRef}
onBegin={() => setSignTip('')}
onEnd={() => {
onChange?.(sigCanvasRef?.current?.toDataURL());
}}
/>
{signTip && (
<div className='SignatureTips'>
{tipText &&
tipText?.map((item, index) => (
<View key={`${index.toString()}`} className='tip-text'>
{item}
</View>
))}
</div>
)}
</View>
</View>
);
};
export default SignatureBoard;
inde.less
- 注意:由于浏览器顶部地址栏和底部工具栏的原因,fixed固定定位之后的宽高要使用100%,而不是 100vh 或 100vw
@media screen and (orientation: portrait) {
.signature-board-comp {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 9;
display: flex;
flex-wrap: nowrap;
align-items: stretch;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 48px 52px 48px 0px;
background-color: #ffffff;
.sign-board-btns {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
box-sizing: border-box;
width: 142px;
padding: 0px 24px;
.board-btn {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 312px;
margin-top: 32px;
border: 1px solid #181916;
border-radius: 8px;
opacity: 1;
&:active {
opacity: 0.9;
}
.board-btn-text {
color: #181916;
font-size: 30px;
transform: rotate(90deg);
}
}
.confirm-btn {
color: #ffffff;
background: #181916;
.board-btn-text {
color: #ffffff;
}
}
}
.sign-board {
position: relative;
flex: 1;
.sigCanvas {
width: 100%;
height: 100%;
background: #f7f7f7;
border-radius: 10px;
}
.SignatureTips {
position: absolute;
top: 0;
left: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 50px;
height: 100%;
color: #a2a0a8;
font-size: 46px;
transform: translateX(-50%);
pointer-events: none;
.tip-text {
line-height: 50px;
transform: rotate(90deg);
}
}
}
}
}
@media screen and (orientation: landscape) {
.signature-board-comp {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 9;
display: flex;
flex-direction: column-reverse;
flex-wrap: nowrap;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0px 48px 0px 48px;
background-color: #ffffff;
.sign-board-btns {
display: flex;
flex-wrap: nowrap;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
box-sizing: border-box;
width: 100%;
height: 20vh;
padding: 12px 0px;
.board-btn {
display: flex;
align-items: center;
justify-content: center;
width: 156px;
height: 100%;
max-height: 48px;
margin-left: 16px;
border: 1px solid #181916;
border-radius: 4px;
opacity: 1;
&:active {
opacity: 0.9;
}
.board-btn-text {
color: #181916;
font-size: 15px;
}
}
.confirm-btn {
color: #ffffff;
background: #181916;
.board-btn-text {
color: #ffffff;
}
}
}
.sign-board {
position: relative;
flex: 1;
box-sizing: border-box;
height: 80vh;
.sigCanvas {
box-sizing: border-box;
width: 100%;
height: 80vh;
background: #f7f7f7;
border-radius: 5px;
}
.SignatureTips {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
color: #a2a0a8;
font-size: 23px;
pointer-events: none;
}
}
}
}
utils.ts
export const rotateImg = (src, callback, deg = 270) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const image = new Image();
image.crossOrigin = 'anonymous';
image.src = src;
image.onload = function () {
const imgW = image.width;
const imgH = image.height;
const size = imgW > imgH ? imgW : imgH;
canvas.width = size * 2;
canvas.height = size * 2;
const cutCoor = {
sx: size,
sy: size - imgW,
ex: size + imgH,
ey: size + imgW
};
ctx?.translate(size, size);
ctx?.rotate((deg * Math.PI) / 180);
ctx?.drawImage(image, 0, 0);
const imgData = ctx?.getImageData(cutCoor.sx, cutCoor.sy, cutCoor.ex, cutCoor.ey);
canvas.width = imgH;
canvas.height = imgW;
ctx?.putImageData(imgData as any, 0, 0);
callback(canvas.toDataURL());
};
};
export const Toast = ({ title, duration = 2000, rotate = 0 }) => {
const _duration = isNaN(duration) ? 2000 : duration;
const divElement = document.createElement('div');
divElement.innerHTML = title;
divElement.style.cssText = `position: fixed;top: 50%;left: 50%;z-index: 99;display: flex;
align-items: center;justify-content: center;padding: 12px 16px;color: #ffffff;font-size: 15px;
background: rgba(0, 0, 0, 0.8);border-radius: 4px;transform: translate(-50%, -50%) rotate(${rotate}deg);`;
document.body.appendChild(divElement);
setTimeout(() => {
const d = 0.1;
divElement.style.transition = `opacity ${d}s linear 0s`;
divElement.style.opacity = '0';
setTimeout(function () {
document.body.removeChild(divElement);
}, d * 1000);
}, _duration);
};
export const throttle = (fn, delay = 2000) => {
let timer: any = null;
return (...args) => {
if (timer) {
return;
}
fn.call(undefined, ...args);
timer = setTimeout(() => {
timer = null;
}, delay);
};
};
三、实现效果