实现一个丝滑的Vue3手写板组件,兼容PC跟H5

需求描述

基于Vue3实现一个可以导出图片、清除的手写板组件,用于用户手写签名并上传的需求。

实现思路

通过我们平时书写的经验,大致可以分成3个阶段:

  1. 落笔
  2. 运笔
  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>

效果预览

明天补吧,扛不住了!

posted @ 2023-03-13 03:12  大兰科技  阅读(780)  评论(0编辑  收藏  举报