vue3 移动端使用canvas手写签名
Sonder
2023-05-19
18408字
46分钟
浏览 (1.2k)
效果与素材
![移动端签名.gif](https://s2.loli.net/2023/05/19/CxyAmUaGl3tS7fn.gif)
![ico-sign-bg.png](https://s2.loli.net/2023/05/19/AIELRhuznM4QKFb.png)
父组件调用签名组件
<div class="trigger-area" @click="popShow.sign = true">手动签名</div>
<UDSignPopUp v-model:show="popShow.sign" @onConfirm="confirmSign" />
// 回调的结果
const confirmSign = (params) => {
popShow.sign = false
// base64上传后的图片链接
console.log(`params===>`, params)
}
签名组件
<template>
<van-popup v-model:show="show" position="bottom" safe-area-inset-bottom :style="horizontalStyle"
:round="!isFullScreen" @opened="initCanvas" @closed="onClosed">
<div :class="{ 'full-screen': isFullScreen }">
<div class="sign-header">
<span class="cancel" @click="close">取消</span>
<span class="title">手动签名</span>
<span class="confirm" @click="confirm">确定</span>
</div>
<div class="content" @touchmove="showSignPlaceholder = false">
<img class="sign-bg" src="./img/ico-sign-bg.png" />
<div v-if="showSignPlaceholder" class="sign-placeholder">
<span class="line1">在此签名</span>
<span class="line2">请使用正楷签字</span>
</div>
<canvas ref="canvasRef" class="sign-canvas"></canvas>
</div>
<div class="sign-footer">
<div class="sign-btn" @click="onClickFullScreen">
<img src="./img/ico-sign-full-screen.svg" />
<span>{{ isFullScreen ? '退出全屏' : '全屏' }}</span>
</div>
<div class="sign-btn" @click="resetSign">
<img src="./img/ico-sign-refresh.svg" />
<span>重新签名</span>
</div>
</div>
</div>
</van-popup>
</template>
<script setup>
import { computed, ref, nextTick } from "vue";
import Draw from './draw';
import { uploadBase } from '@/apis/login';
import { Toast } from 'vant';
const props = defineProps({
show: {
type: Boolean,
default: false,
}
})
const isFullScreen = ref(false);
const showSignPlaceholder = ref(true);
const emits = defineEmits([
'onClose', 'onConfirm', 'update:show'
])
//toRef update外部的值会readonly
const show = computed({
get() {
return props.show;
},
set(value) {
emits('update:show', value);
}
})
//计算全屏样式
const degree = computed(() => {
//全屏为顺时针90度
return isFullScreen.value ? 90 : 0;
}, {})
const canvasRef = ref();
const draw = ref(null);
const initCanvas = () => {
if (!canvasRef.value) {
return;
}
setTimeout(() => {
draw.value = new Draw(canvasRef.value, -degree.value, {
addEvent: !draw.value
})
}, 200);
}
const horizontalStyle = computed(() => {
const d = document;
const w = window.innerWidth || d.documentElement.clientWidth || d.body.clientWidth;
const h = window.innerHeight || d.documentElement.clientHeight || d.body.clientHeight;
let length = (h - w) / 2;
let width = w;
let height = h;
switch (degree.value) {
case -90:
length = -length;
case 90:
width = h;
height = w;
break;
default:
length = 0;
}
nextTick(initCanvas);
//只是一部分 还有内部的样式 需要.full-screen配合
return isFullScreen.value && {
transform: `rotate(${degree.value}deg) translate(${length}px,${length}px)`,
width: `${width}px`,
height: `${height}px`,
transformOrigin: 'center center',
//适配vantPopUp
padding: '0',
top: '0',
};
})
const onClickFullScreen = (params) => {
isFullScreen.value = !isFullScreen.value;
}
const resetSign = (params) => {
showSignPlaceholder.value = true;
draw.value && draw.value.clear();
}
const close = (params) => {
show.value = false;
emits('onClose')
}
//关闭弹出层且动画结束后触发
const onClosed = (params) => {
isFullScreen.value = false
resetSign();
}
const confirm = (params) => {
let content = draw.value.getPNGImage();
console.log(`content===>`, content) //data:image/png;base64,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
//上传base64接口
uploadBase({ content }).then((res) => {
if (res.data && res.data.url) {
emits('onConfirm', res.data.url);
close();
} else {
Toast("保存签名失败,请重试");
}
});
}
</script>
<style lang="less" scoped>
:root {
--ud-blue: #6fb400;
}
.sign-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #E6E6E6;
color: var(--ud-blue);
.cancel,
.confirm {
font-size: 16px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
line-height: 16px;
}
.cancel {
color: rgba(0, 0, 0, 0.45);
}
.title {
font-size: 18px;
font-family: PingFangSC-Medium, PingFang SC;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
line-height: 18px;
}
}
.content {
position: relative;
margin: 23px 23px 0;
color: var(--ud-blue);
.sign-bg {
display: block;
width: 100%;
height: 100%;
}
.sign-placeholder {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.line1 {
font-size: 30px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
line-height: 30px;
filter: opacity(0.35);
}
.line2 {
margin-top: 18px;
font-size: 14px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
line-height: 14px;
filter: opacity(0.44);
}
}
.sign-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
}
.sign-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 19px 24px 24px;
color: var(--ud-blue);
.sign-btn {
display: flex;
align-items: center;
font-size: 14px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
line-height: 14px;
}
}
//全屏样式
.full-screen {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
.content {
flex: 1;
overflow: hidden;
}
}
</style>
draw.js
function Draw(canvas, degree, config = {}, contextConfig = {}) {
if (!(this instanceof Draw)) {
return new Draw(canvas, degree, config, contextConfig);
}
if (!canvas) {
return;
}
let drawConfig = Object.assign({ addEvent: true }, config)
//先去掉 重新获取宽高
canvas.style.width = ``;
canvas.style.height = ``;
let { width, height } = window.getComputedStyle(canvas, null);
width = width.replace('px', '');
height = height.replace('px', '');
this.canvas = canvas;
this.context = canvas.getContext('2d');
this.width = width;
this.height = height;
const context = this.context;
// 根据设备像素比优化canvas绘图
const devicePixelRatio = window.devicePixelRatio;
if (devicePixelRatio) {
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.height = height * devicePixelRatio;
canvas.width = width * devicePixelRatio;
context.scale(devicePixelRatio, devicePixelRatio);
} else {
canvas.width = width;
canvas.height = height;
}
context.lineWidth = 6;
context.strokeStyle = 'black';
context.lineCap = 'round';
context.lineJoin = 'round';
Object.assign(context, config);
// const canvasBoundRect = canvas.getBoundingClientRect();
const point = {};
const isMobile = /phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone/i.test(navigator.userAgent);
// 移动端性能太弱, 去掉模糊以提高手写渲染速度
if (!isMobile) {
context.shadowBlur = 1;
context.shadowColor = 'black';
}
let pressed = false;
const paint = (signal) => {
switch (signal) {
case 1:
context.beginPath();
context.moveTo(point.x, point.y);
case 2:
context.lineTo(point.x, point.y);
context.stroke();
break;
default:
}
};
const create = signal => (e) => {
e.preventDefault();
if (signal === 1) {
pressed = true;
}
if (signal === 1 || pressed) {
e = isMobile ? e.touches[0] : e;
const canvasBoundRect = canvas.getBoundingClientRect();
point.x = e.clientX - canvasBoundRect.left;
point.y = e.clientY - canvasBoundRect.top;
paint(signal);
}
};
const start = create(1);
const move = create(2);
const requestAnimationFrame = window.requestAnimationFrame;
const optimizedMove = requestAnimationFrame ? (e) => {
requestAnimationFrame(() => {
move(e);
});
} : move;
if (drawConfig.addEvent) {
if (isMobile) {
canvas.addEventListener('touchstart', start);
canvas.addEventListener('touchmove', optimizedMove);
} else {
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', optimizedMove);
['mouseup', 'mouseleave'].forEach((event) => {
canvas.addEventListener(event, () => {
pressed = false;
});
});
}
}
// 重置画布坐标系
if (typeof degree === 'number') {
this.degree = degree;
context.rotate((degree * Math.PI) / 180);
switch (degree) {
case -90:
context.translate(-height, 0);
break;
case 90:
context.translate(0, -width);
break;
case -180:
case 180:
context.translate(-width, -height);
break;
default:
}
}
}
Draw.prototype = {
scale(width, height, canvas = this.canvas) {
const w = canvas.width;
const h = canvas.height;
width = width || w;
height = height || h;
if (width !== w || height !== h) {
const tmpCanvas = document.createElement('canvas');
const tmpContext = tmpCanvas.getContext('2d');
tmpCanvas.width = width;
tmpCanvas.height = height;
tmpContext.drawImage(canvas, 0, 0, w, h, 0, 0, width, height);
canvas = tmpCanvas;
}
return canvas;
},
rotate(degree, image = this.canvas) {
degree = ~~degree;
if (degree !== 0) {
const maxDegree = 180;
const minDegree = -90;
if (degree > maxDegree) {
degree = maxDegree;
} else if (degree < minDegree) {
degree = minDegree;
}
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const height = image.height;
const width = image.width;
const degreePI = (degree * Math.PI) / 180;
switch (degree) {
// 逆时针旋转90°
case -90:
canvas.width = height;
canvas.height = width;
context.rotate(degreePI);
context.drawImage(image, -width, 0);
break;
// 顺时针旋转90°
case 90:
canvas.width = height;
canvas.height = width;
context.rotate(degreePI);
context.drawImage(image, 0, -height);
break;
// 顺时针旋转180°
case 180:
canvas.width = width;
canvas.height = height;
context.rotate(degreePI);
context.drawImage(image, -width, -height);
break;
default:
}
image = canvas;
}
return image;
},
getPNGImage(canvas = this.canvas) {
return canvas.toDataURL('image/png');
},
getJPGImage(canvas = this.canvas) {
return canvas.toDataURL('image/jpeg', 0.5);
},
downloadPNGImage(image) {
const url = image.replace('image/png', 'image/octet-stream;Content-Disposition:attachment;filename=test.png');
window.location.href = url;
},
dataURLtoBlob(dataURL) {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bStr = atob(arr[1]);
let n = bStr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bStr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
},
clear() {
let width;
let height;
switch (this.degree) {
case -90:
case 90:
width = this.height;
height = this.width;
break;
default:
width = this.width;
height = this.height;
}
this.context.clearRect(0, 0, width, height);
},
upload(blob, url, success, failure) {
const formData = new FormData();
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
formData.append('image', blob, 'sign');
xhr.open('POST', url, true);
xhr.onload = () => {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
success(xhr.responseText);
} else {
failure();
}
};
xhr.onerror = (e) => {
if (typeof failure === 'function') {
failure(e);
} else {
console.log(`upload img error: ${e}`);
}
};
xhr.send(formData);
},
};
export default Draw;