mini-smooth-signature 小程序版带笔锋手写签名,支持多平台小程序使用
参考:GitHub - linjc/smooth-signature: H5带笔锋手写签名,支持PC端和移动端,任何前端框架均可使用
一、安装
npm install smooth-signature
# 或
yarn add smooth-signature
或通过<script>引用,全局变量 window.SmoothSignature
<script src="https://unpkg.com/smooth-signature/dist/index.umd.min.js" />
也可自行下载smooth-signature.js到本地引用
<div><canvas />
</div>
import SmoothSignature from "smooth-signature";const canvas = document.querySelector("canvas");
const signature = new SmoothSignature(canvas);// 生成PNG
signature.getPNG() // 或者 signature.toDataURL()// 生成JPG
signature.getJPG() // 或者 signature.toDataURL('image/jpeg')// 清屏
signature.clear()// 撤销
signature.undo()// 是否为空
signature.isEmpty()// 生成旋转后的新画布 -90/90/-180/180
signature.getRotateCanvas(90)
所有配置项均是可选的
const signature = new SmoothSignature(canvas, {width: 1000,height: 600,scale: 2,minWidth: 4,maxWidth: 10,color: '#1890ff',bgColor: '#efefef'
});
options.width
画布在页面实际渲染的宽度(px)
- Type:
number
- Default:canvas.clientWidth || 320
options.height
画布在页面实际渲染的高度(px)
- Type:
number
- Default:canvas.clientHeight || 200
options.scale
画布缩放,可用于提高清晰度
- Type:
number
- Default:window.devicePixelRatio || 1
options.color
画笔颜色
- Type:
string
- Default:black
options.bgColor
画布背景颜色,默认透明
- Type:
string
- Default:
options.openSmooth
是否开启笔锋效果,默认开启
- Type:
boolean
- Default:true
options.minWidth
画笔最小宽度(px),开启笔锋时画笔最小宽度
- Type:
number
- Default:2
options.maxWidth
画笔最大宽度(px),开启笔锋时画笔最大宽度,或未开启笔锋时画笔正常宽度
- Type:
number
- Default:6
options.minSpeed
画笔达到最小宽度所需最小速度(px/ms),取值范围1.0-10.0,值越小,画笔越容易变细,笔锋效果会比较明显,可以自行调整查看效果,选出自己满意的值。
- Type:
number
- Default:1.5
options.maxWidthDiffRate
相邻两线宽度增(减)量最大百分比,取值范围1-100,为了达到笔锋效果,画笔宽度会随画笔速度而改变,如果相邻两线宽度差太大,过渡效果就会很突兀,使用maxWidthDiffRate限制宽度差,让过渡效果更自然。可以自行调整查看效果,选出自己满意的值。
- Type:
number
- Default:20
options.maxHistoryLength
限制历史记录数,即最大可撤销数,传入0则关闭历史记录功能
- Type:
number
- Default:20
options.onStart
绘画开始回调函数
- Type:
function
options.onEnd
绘画结束回调函数
- Type:
function
我们平时纸上写字,细看会发现笔画的粗细是不均匀的,这是写字过程中,笔的按压力度和移动速度不同而形成的。而在电脑手机浏览器上,虽然我们无法获取到触摸的压力,但可以通过画笔移动的速度来实现不均匀的笔画效果,让字体看起来和纸上写字一样有“笔锋”。下面介绍具体实现过程(以下展示代码只为方便理解,非最终实现代码)。
通过监听画布move事件采集移动经过的点坐标,并记录当前时间,然后保存到points数组中。
function onMove(event) {const e = event.touches && event.touches[0] || event;const rect = this.canvas.getBoundingClientRect();const point = {x: e.clientX - rect.left,y: e.clientY - rect.top,t: Date.now()}points.push(point);
}
通过两点坐标计算出两点距离,再除以时间差,即可得到移动速度。
const distance = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
const speed = distance / (end.t - start.t);
得到两点间移动速度,接下来通过简单算法计算出线的宽度,其中maxWidth、minWidth、minSpeed为配置项
const addWidth = (maxWidth - minWidth) * speed / minSpeed;
const lineWidth = Math.min(Math.max(maxWidth - addWidth, minWidth), maxWidth);
另外,为了防止相邻两条线宽度差太大,而出现突兀的过渡效果,需要做下限制,其中maxWidthDiffRate为配置项,preLineWidth为上一条线的宽度
const rate = (lineWidth - preLineWidth) / preLineWidth;
const maxRate = maxWidthDiffRate / 100;
if (Math.abs(rate) > maxRate) {const per = rate > 0 ? maxRate : -maxRate;lineWidth = preLineWidth * (1 + per);
}
现在已经知道每两点间线的宽度,接下来就是画线了。为了让线条看起来圆润以及线粗细过渡更自然,我把两点之间的线平均成三段,其中:
- 第一段(x0,y0 - x1,y1)线宽设置为当前线宽和上一条线宽的平均值lineWidth1 = (preLineWidth + lineWidth) / 2
- 第二段(x1,y1 - x2,y2)
- 第三段(x2,y2 - next_x0,next_y0)线宽设置为当前线宽和下一条线宽的平均值lineWidth3 = (nextLineWidth + lineWidth) / 2
开始画线,先来看第一段线,因为第一段线和上一条线相交,为了保证两条线过渡比较圆润,采用二次贝塞尔曲线,起点为上一条线的第三段起点(pre_x2, pre_y2)
ctx.lineWidth = lineWidth1
ctx.beginPath();
ctx.moveTo(pre_x2, pre_y2);
ctx.quadraticCurveTo(x0, y0, x1, y1);
ctx.stroke();
第二段线为承接第一段和第三段的过渡线,由于第一段和第三段线宽有差异,所以第二段线使用梯形填充,让过渡效果更自然。
ctx.beginPath();
ctx.moveTo(point1.x, point1.y);
ctx.lineTo(point2.x, point2.y);
ctx.lineTo(point3.x, point3.y);
ctx.lineTo(point4.x, point4.y);
ctx.fill();
第三段等画下一条线时重复上述操作即可。
四、例子
(1)PC版
<template><a-modal :default-visible="true" width="880px" :footer="false"><template #title>签约</template><div class="wrapper"><canvas id="drag-canvas" canvas-id="drag-canvas" style="width: 800px; height: 400px" /><div class="actions"><a-button class="btn" @click="handleClear">清空</a-button><a-button class="btn" @click="handleUndo">撤销</a-button><a-button class="btn" type="primary" @click="handlePreview">确认</a-button></div></div></a-modal>
</template><script setup lang="ts">
import SmoothSignature from 'smooth-signature';
import { Message } from '@arco-design/web-vue';let canvas = {} as HTMLCanvasElement;
let signature = {} as SmoothSignature;
const init = () => {canvas = document.querySelector('canvas') as HTMLCanvasElement;const options = {width: 800,height: 400,minWidth: 4,maxWidth: 12,bgColor: '#f6f6f6',};signature = new SmoothSignature(canvas, options);
};const handleClear = () => {signature.clear();
};
const handleUndo = () => {signature.undo();
};
const emits = defineEmits(['handlePreview']);
const handlePreview = () => {const isEmpty = signature.isEmpty();if (isEmpty) {Message.warning('签名不得为空');return;}const pngUrl = signature.getPNG();emits('handlePreview', pngUrl);
};
setTimeout(() => {init();
}, 1000);
</script><style scoped lang="less">
.wrapper {padding: 15px 20px;canvas {border: 2px dashed #ccc;cursor: crosshair;margin: auto;display: block;}.actions {margin: 30px 0 0px;display: flex;text-align: center;.btn {flex: 1;margin-right: 20px;font-size: 18px !important;height: 56px !important;padding: 0 20px;&:last-child {margin-right: 0;}}}.tip {color: #108eff;}
}
</style>
(2)小程序版
<template><!-- 签名 --><view class="sign-box" v-if="signShow"><view class="btn"><button class="item" @click="cancel"><text class="text">取消</text></button><button class="item" @click="clear"><text class="text">清空</text></button><button class="item active" @click="save"><text class="text">确定</text></button></view><canvasclass="canvas"disable-scroll="true":style="{ width: width + 'px', height: height + 'px' }"canvas-id="designature"id="designature"@touchstart="start"@touchmove="move"@touchend="end"></canvas><view class="title"><text class="text">请签字</text></view></view><!-- --><viewclass="sign-small-box":class="(isSign == false && signImage == '') || sign_image_url ? 'border' : ''"@click="show"><view class="tip" v-if="isSign == false && signImage == ''">点击这里签署您的姓名 </view><image class="sign-img" :src="signImage" v-else mode="heightFix" /></view><view class="bottom-tip" v-if="sign_image_url">请点击上方签名图片重新签名</view><!-- 旋转画布 --><view />
</template><script setup lang="ts">import { nextTick, ref } from 'vue';import SmoothSignature from 'mini-smooth-signature';import { onLoad } from '@dcloudio/uni-app';const signShow = ref(false);const signImage = ref('');const width = ref(window.innerWidth - 110); //const height = ref(window.innerHeight - 100);const line = ref([]);const scale = ref(1);const isSign = ref(false);const signature = ref(null);const props = defineProps({sign_image_url: {type: String,},});const show = () => {signShow.value = true;nextTick(() => {initSignature();});};const cancel = () => {uni.navigateBack();};const distance = (a: any, b: any) => {let x = b.x - a.x;let y = b.y - a.y;return Math.sqrt(x * x + y * y);};// 初始化const initSignature = () => {const ctx = uni.createCanvasContext('designature');signature.value = new SmoothSignature(ctx, {minWidth: 4,maxWidth: 10,width: width.value,height: height.value,scale: scale.value,getImagePath: () => {return new Promise((resolve) => {uni.canvasToTempFilePath({canvasId: 'designature',fileType: 'png',quality: 1, //图片质量success(res) {resolve(res.tempFilePath);},},this,);});},});};// 绑定touchstart事件const start = (e: any) => {isSign.value = false;const pos = e.touches[0];signature.value.onDrawStart(pos.x, pos.y);};// 绑定touchmove事件const move = (e: any) => {const pos = e.touches[0];signature.value.onDrawMove(pos.x, pos.y);};// 绑定touchend/touchcancel事件const end = () => {signature.value.onDrawEnd();isSign.value = true;};const clear = () => {signature.value.clear();isSign.value = false;};const emit = defineEmits(['backImage']);const save = () => {if (!isSign.value) {uni.showToast({title: '请先签字再保存',icon: 'none',});return;}signShow.value = false;signature.value.getImagePath().then((imageUrl) => {signImage.value = imageUrl;emit('backImage', imageUrl);});};onLoad(() => {if (props.sign_image_url) {signImage.value = props.sign_image_url;}uni.getSystemInfo({success: (res) => {width.value = res.windowWidth - 110;height.value = res.windowHeight - 100;},});});
</script><style lang="scss" scoped>.sign-small-box {overflow: hidden;width: 90%;height: 340rpx;cursor: pointer;margin: 0 auto 30rpx;display: flex;align-items: center;justify-content: center;&.border {border: 2rpx dashed #ccc;border-radius: 20rpx;}.tip {display: flex;align-items: center;justify-content: center;color: #999;width: 100%;height: 100%;font-size: 28rpx;}.sign-img {transform: rotate(-90deg);}}.bottom-tip {font-size: 26rpx;display: block;color: #999;width: 90%;margin: 0 auto 30rpx;}.sign-box {display: flex;align-items: center;justify-content: center;position: fixed;top: 0;left: 0;right: 0;bottom: 0;z-index: 1;background: #fff;.title {font-size: 40rpx;font-weight: bold;white-space: nowrap;letter-spacing: 12rpx;width: 110rpx;min-width: 110rpx;display: flex;align-items: center;justify-content: center;position: relative;.text {position: absolute;top: 0;bottom: 0;right: -60rpx;transform: rotate(90deg);width: 200rpx;}}.canvas {border: 2rpx dashed #ccc;border-radius: 20rpx;overflow: hidden;cursor: crosshair;}.btn {width: 120rpx;min-width: 120rpx;white-space: nowrap;height: 100%;display: grid;.item {flex: 1;display: flex;align-items: center;justify-content: center;font-size: 34rpx;background: none;box-sizing: border-box;padding: 0;letter-spacing: 10rpx;position: relative;width: 100%;color: #666;cursor: pointer;.text {display: block;transform: rotate(90deg);}&.active {color: $uni-main-color;}&:after {content: '';position: absolute;top: 0;background: #eee;width: 100%;left: 50%;transform: translate(-50%, 0%);height: 1rpx;display: block;}&:first-child {&:after {display: none;}}}}}
</style>