vue3 smooth-signature 带笔锋手写签名
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> <canvas class="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> <!-- --> <view class="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>
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了