本文為轉(zhuǎn)載,轉(zhuǎn)載原文出處:
驀然JL-vue3 smooth-signature 帶筆鋒手寫簽名
vue3 smooth-signature 帶筆鋒手寫簽名
mini-smooth-signature 小程序版帶筆鋒手寫簽名,支持多平臺(tái)小程序使用
參考:GitHub - linjc/smooth-signature: H5帶筆鋒手寫簽名,支持PC端和移動(dòng)端,任何前端框架均可使用
一、安裝
npm install smooth-signature
# 或
yarn add smooth-signature
或通過(guò)<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()
// 生成旋轉(zhuǎn)后的新畫布 -90/90/-180/180
signature.getRotateCanvas(90)
配置[options]
所有配置項(xiàng)均是可選的
const signature = new SmoothSignature(canvas, {
width: 1000,
height: 600,
scale: 2,
minWidth: 4,
maxWidth: 10,
color: '#1890ff',
bgColor: '#efefef'
});
options.width
畫布在頁(yè)面實(shí)際渲染的寬度(px)
- Type:
number - Default:canvas.clientWidth || 320
options.height
畫布在頁(yè)面實(shí)際渲染的高度(px)
- Type:
number - Default:canvas.clientHeight || 200
options.scale
畫布縮放,可用于提高清晰度
- Type:
number - Default:window.devicePixelRatio || 1
options.color
畫筆顏色
- Type:
string - Default:black
options.bgColor
畫布背景顏色,默認(rèn)透明
- Type:
string - Default:
options.openSmooth
是否開啟筆鋒效果,默認(rèn)開啟
- Type:
boolean - Default:true
options.minWidth
畫筆最小寬度(px),開啟筆鋒時(shí)畫筆最小寬度
- Type:
number - Default:2
options.maxWidth
畫筆最大寬度(px),開啟筆鋒時(shí)畫筆最大寬度,或未開啟筆鋒時(shí)畫筆正常寬度
- Type:
number - Default:6
options.minSpeed
畫筆達(dá)到最小寬度所需最小速度(px/ms),取值范圍1.0-10.0,值越小,畫筆越容易變細(xì),筆鋒效果會(huì)比較明顯,可以自行調(diào)整查看效果,選出自己滿意的值。
- Type:
number - Default:1.5
options.maxWidthDiffRate
相鄰兩線寬度增(減)量最大百分比,取值范圍1-100,為了達(dá)到筆鋒效果,畫筆寬度會(huì)隨畫筆速度而改變,如果相鄰兩線寬度差太大,過(guò)渡效果就會(huì)很突兀,使用maxWidthDiffRate限制寬度差,讓過(guò)渡效果更自然??梢宰孕姓{(diào)整查看效果,選出自己滿意的值。
- Type:
number - Default:20
options.maxHistoryLength
限制歷史記錄數(shù),即最大可撤銷數(shù),傳入0則關(guān)閉歷史記錄功能
- Type:
number - Default:20
options.onStart
繪畫開始回調(diào)函數(shù)
- Type:
function
options.onEnd
繪畫結(jié)束回調(diào)函數(shù)
- Type:
function
三、實(shí)現(xiàn)原理
我們平時(shí)紙上寫字,細(xì)看會(huì)發(fā)現(xiàn)筆畫的粗細(xì)是不均勻的,這是寫字過(guò)程中,筆的按壓力度和移動(dòng)速度不同而形成的。而在電腦手機(jī)瀏覽器上,雖然我們無(wú)法獲取到觸摸的壓力,但可以通過(guò)畫筆移動(dòng)的速度來(lái)實(shí)現(xiàn)不均勻的筆畫效果,讓字體看起來(lái)和紙上寫字一樣有“筆鋒”。下面介紹具體實(shí)現(xiàn)過(guò)程(以下展示代碼只為方便理解,非最終實(shí)現(xiàn)代碼)。
1、采集畫筆經(jīng)過(guò)的點(diǎn)坐標(biāo)和時(shí)間
通過(guò)監(jiān)聽畫布move事件采集移動(dòng)經(jīng)過(guò)的點(diǎn)坐標(biāo),并記錄當(dāng)前時(shí)間,然后保存到points數(shù)組中。
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);
}
2、計(jì)算兩點(diǎn)之間移動(dòng)速度
通過(guò)兩點(diǎn)坐標(biāo)計(jì)算出兩點(diǎn)距離,再除以時(shí)間差,即可得到移動(dòng)速度。
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);
3、計(jì)算兩點(diǎn)之間線的寬度
得到兩點(diǎn)間移動(dòng)速度,接下來(lái)通過(guò)簡(jiǎn)單算法計(jì)算出線的寬度,其中maxWidth、minWidth、minSpeed為配置項(xiàng)
const addWidth = (maxWidth - minWidth) * speed / minSpeed;
const lineWidth = Math.min(Math.max(maxWidth - addWidth, minWidth), maxWidth);
另外,為了防止相鄰兩條線寬度差太大,而出現(xiàn)突兀的過(guò)渡效果,需要做下限制,其中maxWidthDiffRate為配置項(xiàng),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);
}
4、畫線
現(xiàn)在已經(jīng)知道每?jī)牲c(diǎn)間線的寬度,接下來(lái)就是畫線了。為了讓線條看起來(lái)圓潤(rùn)以及線粗細(xì)過(guò)渡更自然,我把兩點(diǎn)之間的線平均成三段,其中:
- 第一段(x0,y0 - x1,y1)線寬設(shè)置為當(dāng)前線寬和上一條線寬的平均值lineWidth1 = (preLineWidth + lineWidth) / 2
- 第二段(x1,y1 - x2,y2)
- 第三段(x2,y2 - next_x0,next_y0)線寬設(shè)置為當(dāng)前線寬和下一條線寬的平均值lineWidth3 = (nextLineWidth + lineWidth) / 2
開始畫線,先來(lái)看第一段線,因?yàn)榈谝欢尉€和上一條線相交,為了保證兩條線過(guò)渡比較圓潤(rùn),采用二次貝塞爾曲線,起點(diǎn)為上一條線的第三段起點(diǎn)(pre_x2, pre_y2)
ctx.lineWidth = lineWidth1
ctx.beginPath();
ctx.moveTo(pre_x2, pre_y2);
ctx.quadraticCurveTo(x0, y0, x1, y1);
ctx.stroke();
第二段線為承接第一段和第三段的過(guò)渡線,由于第一段和第三段線寬有差異,所以第二段線使用梯形填充,讓過(guò)渡效果更自然。
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();
第三段等畫下一條線時(shí)重復(fù)上述操作即可。
四、例子
(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">確認(rèn)</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">請(qǐng)簽字</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 == ''">點(diǎn)擊這里簽署您的姓名 </view>
<image class="sign-img" :src="signImage" v-else mode="heightFix" />
</view>
<view class="bottom-tip" v-if="sign_image_url">請(qǐng)點(diǎn)擊上方簽名圖片重新簽名</view>
<!-- 旋轉(zhuǎn)畫布 -->
<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, //圖片質(zhì)量
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: '請(qǐng)先簽字再保存',
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>