实现一个丝滑的Vue3手写板组件,兼容PC跟H5
需求描述
基于Vue3实现一个可以导出图片、清除的手写板组件,用于用户手写签名并上传的需求。
实现思路
通过我们平时书写的经验,大致可以分成3个阶段:
- 落笔
- 运笔
- 提笔
然后通过touch/mouse事件判断以上3个阶段,并在canvas上进行绘制,最后再通过canvas的toDataURL方法进行导出。
准备工作
开始实现之前,需要进行一些准备工作:
canvas初始化
<template>
<canvas
ref="tablet"
@touchstart="onStart"
@touchmove="onMove"
@touchend="onEnd"
@mousedown="onStart"
@mouseup="onEnd"
@mousemove="onMove"
></canvas>
</template>
// 设备像素比,对canvas进行缩放提高导出图的清晰度
const ratio = window.devicePixelRatio || 1;
let canvas!: HTMLCanvasElement;
let ctx!: CanvasRenderingContext2D;
// 支持修改手写板高/宽
const props = defineProps<{
width: number;
height: number;
}>();
const tablet = ref<HTMLCanvasElement>();
onMounted(() => {
canvas = tablet.value as HTMLCanvasElement;
canvas.width = props.width * ratio;
canvas.height = props.width * ratio;
ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
// 绘制白色背景
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width * ratio, canvas.height * ratio);
});
准备状态及工具函数
// 书写状态,用于控制绘画
let writing = false;
// 将payload转换成坐标,主要用于兼容PC跟H5
const coord = (payload: Partial<Event>) => {
const { touches, clientX = 0, clientY = 0 } = payload;
if (touches && touches.length) {
const { clientX, clientY } = touches[0];
return {
clientX,
clientY,
};
}
return { clientX, clientY };
};
实现落笔-运笔-提笔
落笔
状态设置为书写中,开始新路径并设置起始点。
const onStart = (payload: Partial<Event>) => {
writing = true;
const { clientX, clientY } = coord(payload);
ctx.beginPath();
ctx.moveTo(clientX * ratio, clientY * ratio);
ctx.lineCap = "round";
ctx.lineJoin = "round";
};
运笔
当状态为书写中时,通过touchmove/mousemove事件进行绘制。
const onMove = (payload: Partial<Event>) => {
if (!writing) {
return;
}
const { clientX, clientY } = coord(payload);
ctx.lineTo(clientX * ratio, clientY * ratio);
ctx.lineWidth = 3 * ratio;
ctx.stroke();
payload.preventDefault?.();
};
提笔
取消书写中的状态为时,结束绘制。
const onEnd = () => {
writing = false;
};
实现导出、清除功能
const confirm = () => {
return canvas.toDataURL();
};
const clear = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width * ratio, canvas.height * ratio);
};
// 将方法暴露给父组件
defineExpose({ confirm, clear });
完整代码
<script setup lang="ts">
import { onMounted, ref } from "vue";
interface Event {
touches: TouchList;
clientX: number;
clientY: number;
preventDefault: Function;
}
const ratio = window.devicePixelRatio || 1;
let canvas!: HTMLCanvasElement;
let ctx!: CanvasRenderingContext2D;
let writing = false;
const props = defineProps<{
width: number;
height: number;
}>();
const tablet = ref<HTMLCanvasElement>();
/**
* @describe 将payload转换成坐标,主要用于兼容PC跟WAP
*/
const coord = (payload: Partial<Event>) => {
const { touches, clientX = 0, clientY = 0 } = payload;
if (touches && touches.length) {
const { clientX, clientY } = touches[0];
return {
clientX,
clientY,
};
}
return { clientX, clientY };
};
/**
* @describe 落笔
*/
const onStart = (payload: Partial<Event>) => {
writing = true;
const { clientX, clientY } = coord(payload);
ctx.beginPath();
ctx.moveTo(clientX * ratio, clientY * ratio);
ctx.lineCap = "round";
ctx.lineJoin = "round";
};
/**
* @describe 运笔
*/
const onMove = (payload: Partial<Event>) => {
if (!writing) {
return;
}
const { clientX, clientY } = coord(payload);
ctx.lineTo(clientX * ratio, clientY * ratio);
ctx.lineWidth = 3 * ratio;
ctx.stroke();
payload.preventDefault?.();
};
/**
* @describe 提笔
*/
const onEnd = () => {
writing = false;
};
const confirm = () => {
return canvas.toDataURL();
};
const clear = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width * ratio, canvas.height * ratio);
};
defineExpose({ confirm, clear });
onMounted(() => {
canvas = tablet.value as HTMLCanvasElement;
canvas.width = props.width * ratio;
canvas.height = props.width * ratio;
ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width * ratio, canvas.height * ratio);
});
</script>
<template>
<div
class="tablet"
:style="{
width: `${props.width || 300}px`,
height: `${props.height || 300}px`,
}"
>
<canvas
ref="tablet"
@touchstart="onStart"
@touchmove="onMove"
@touchend="onEnd"
@mousedown="onStart"
@mouseup="onEnd"
@mousemove="onMove"
/>
</div>
</template>
<style scoped lang="scss">
.tablet {
canvas {
width: 100%;
height: 100%;
}
}
</style>
效果预览
明天补吧,扛不住了!