vue3+typescript+elementplus 手写ca

作者: 阿巳交不起水电费 | 来源:发表于2023-11-08 21:36 被阅读0次

    功能说明:

    1.支持清空画布。
    2.支持撤销、恢复功能。
    3.支持获取签名后的图片src。
    4.支持下载签名png图片。
    5.获取到的签名图片是裁剪过周围多余空白的。
    6.页面resize后,清空画布,画布自适应父节点大小,建议div 包裹,给外层div设置宽高即可。
    7.可校验签名大小 - 需自行调用校验函数,见demo
    8.支持移动端。
    9.canvas 上面有滚动条也不影响。

    效果如下:

    image.png

    gif演示

    GIF 2023-11-9 17-19-32.gif

    版本依赖如下,element-plus 是拿来错误提示用的

    "element-plus": "^2.3.14","vue": "^3.2.45",
    

    直接上代码,别忘记点赞+收藏哦:

    signCanvas/index.vue

    
    <!-- 
      author: yangfeng
      date: 20231109
      注意: canvas 宽高是获取的父节点的宽高
     -->
    <template>
      <canvas class="signCanvas" ref="canvasDomRef">您的浏览器不支持 HTML5 canvas标签</canvas>
    </template>
    <script lang="ts">
    export default {
      name: 'signCanvas',
    }
    </script>
    <script setup lang="ts">
    import { ref, reactive, onMounted, onUnmounted } from 'vue'
    import { ElMessage } from 'element-plus'
    import { getToPageXY, IsPC, getSignImgPngSrc } from './index'
    
    interface IProps {
      lineColor?: string;
    }
    interface IrectBoundary {
      minX: number;
      minY: number;
      maxX: number;
      maxY: number;
    }
    interface IRecordItem {
      imgData: ImageData;
      rectBoundary: IrectBoundary;
    }
    
    const props = withDefaults(defineProps<IProps>(), {
      lineColor: '#000000', // 线条颜色
    })
    
    // 变量
    const data = reactive({
      isMouseDown: false // 鼠标是否按下
    })
    const canvasDomRef = ref()
    let tickTimer: NodeJS.Timeout | null = null // 防抖
    let resizeObserver: ResizeObserver;
    let isMobile = false
    let rectBoundary: IrectBoundary = { // 签名的最大使用区域,用于裁剪,去掉周围的空白区域
      minX: 0,
      minY: 0,
      maxX: 0,
      maxY: 0
    }
    // 撤销回退
    let undoList: IRecordItem[] = [] // 撤销
    let redoList: IRecordItem[] = [] // 恢复
    
    //#region 签名边界
    
    // 给签名的边界赋值,
    const setRectBoundary = (x: number, y: number) => {
      let { minX, minY, maxX, maxY } = rectBoundary
      rectBoundary.minX = x < minX ? x : minX
      rectBoundary.minY = y < minY ? y : minY
      rectBoundary.maxX = x > maxX ? x : maxX
      rectBoundary.maxY = y > maxY ? y : maxY
    }
    // 给签名的边界初值
    const initRectBoundary = () => {
      let canvas = canvasDomRef.value
      rectBoundary = {
        minX: canvas.width,
        minY: canvas.height,
        maxX: 0,
        maxY: 0
      }
    }
    
    //#endregion 签名边界
    
    //#region 撤销、恢复操作
    
    const setUndoList = () => {
      let canvas = canvasDomRef.value
      let ctx = canvas.getContext('2d')
      undoList.push({
        imgData: ctx.getImageData(0, 0, canvas.width, canvas.height),
        rectBoundary: { // 记录此刻的签名边界
          ...rectBoundary
        }
      })
    }
    
    // 撤销
    const undo = () => {
      if (undoList.length > 0) {
        redoList.push(undoList.pop() as IRecordItem)
      }
      reDrawCanvas()
    }
    // 恢复
    const redo = () => {
      if (redoList.length > 0) {
        undoList.push(redoList.pop() as IRecordItem)
      }
      reDrawCanvas()
    }
    // 将历史记录绘制到画布中
    const reDrawCanvas = () => {
      if (undoList.length) {
        let canvas = canvasDomRef.value
        let ctx = canvas.getContext('2d')
        let record = undoList[undoList.length - 1]
        rectBoundary = record.rectBoundary // 恢复此时的签名边界
        ctx.putImageData(record.imgData, 0, 0);
      } else { // 清空画布
        clear()
      }
    }
    
    // 清空历史记录
    const clearUodoRedoList = () => {
      undoList = []
      redoList = []
    }
    
    //#endregion 撤销、恢复操作
    
    // 转为在canvas画布中的像素
    const getCanvasPx: (arg: { x: number, y: number }) => { x: number; y: number } = ({ x, y }) => {
      let canvas = canvasDomRef.value
      let { left, top } = canvas.getBoundingClientRect()
      return {
        x: x - left,
        y: y - top
      }
    }
    
    // 清空画布
    const clear = () => {
      let canvas = canvasDomRef.value
      if (!canvas) return
      let ctx = canvas.getContext('2d')
      // ctx.save();
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // ctx.restore();
      initRectBoundary() // 清空签名的边界
    }
    
    // resize
    const resizeHandle = () => {
      clearTimeout(Number(tickTimer))
      tickTimer = setTimeout(() => {
        clear()
        clearUodoRedoList() // 每次reize 清空历史记录 - 因为宽高改变恢复了也是变形的
        let canvas = canvasDomRef.value
        if (!canvas) return
        let parentNode = canvas.parentNode
        let wd = parentNode.clientWidth
        let ht = parentNode.clientHeight
        canvas.width = wd
        canvas.height = ht
        // canvas.style.width = wd + 'px'
        // canvas.style.height = ht + 'px'
      }, 100)
    }
    
    // mousedowm
    const downHandle = (e: MouseEvent) => {
      data.isMouseDown = true
      let canvas = canvasDomRef.value
      let { x, y } = getCanvasPx(getToPageXY(e))
      let ctx = canvas.getContext('2d')
      ctx.beginPath();
      ctx.moveTo(x, y);
      setRectBoundary(x, y) // 存储签名的最大使用区域
    }
    
    // mousemove
    const moveHandle = (e: MouseEvent) => {
      if (!data.isMouseDown) return
      let canvas = canvasDomRef.value
      let { x, y } = getCanvasPx(getToPageXY(e))
      let ctx = canvas.getContext('2d')
      ctx.lineTo(x, y);
      ctx.strokeStyle = props.lineColor
      // ctx.lineWidth = 2 * (window.devicePixelRatio || 1)
      ctx.lineWidth = 2
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      //移动端去掉模糊提高手写渲染速度
      if (isMobile) {
        ctx.shadowBlur = 1;
        ctx.shadowColor = props.lineColor;
      }
      ctx.stroke();
      setRectBoundary(x, y) // 存储签名的最大使用区域
    }
    
    // mouseup
    const upHandle = () => {
      data.isMouseDown = false
      setUndoList()
    }
    
    const addEvents = () => {
      let canvas = canvasDomRef.value
      if (!canvas) return
      if (isMobile) {
        canvas.addEventListener('touchstart', downHandle, false)
        canvas.addEventListener('touchmove', moveHandle, false)
        canvas.addEventListener('touchend', upHandle, false)
      } else {
        canvas.addEventListener('pointerdown', downHandle, false)
        canvas.addEventListener('pointermove', moveHandle, false)
        canvas.addEventListener('pointerup', upHandle, false)
      }
    
      // 和传统 window.resize不同 ResizeObserver 可以在div上监听resize
      // 1.指定resize事件
      resizeObserver = new ResizeObserver(resizeHandle) // 会在绘制前和布局后调用 resize 事件,因此不用提前调用 event_windowResize 方法
      // 2.指定该resize事件的触发dom
      resizeObserver.observe(canvas);
    }
    const removeEvents = () => {
      clearTimeout(Number(tickTimer))
      let canvas = canvasDomRef.value
      if (!canvas) return
      if (isMobile) {
        canvas.removeEventListener('touchstart', downHandle)
        canvas.removeEventListener('touchmove', moveHandle)
        canvas.removeEventListener('touchend', upHandle)
      } else {
        canvas.removeEventListener('pointerdown', downHandle)
        canvas.removeEventListener('pointermove', moveHandle)
        canvas.removeEventListener('pointerup', upHandle)
      }
    
      resizeObserver.unobserve(canvas) // 结束对指定 Element 的监听。
    }
    
    // 获取签名后的png图片
    const getSignPNGImgSrc = () => {
      let canvas = canvasDomRef.value
      let { minX, minY, maxX, maxY } = rectBoundary
      if (!maxY && !maxX) { // 未曾签名 - 提示
        ElMessage({
          showClose: true,
          message: '请签名后继续',
          type: 'warning',
        })
        return null
      }
      return getSignImgPngSrc({
        canvas,
        sx: minX,
        sy: minY,
        sw: maxX - minX,
        sh: maxY - minY
      })
    }
    
    // 下载签名图片
    const downLoadSignPNGImg = () => {
      let url = getSignPNGImgSrc()
      if (!url) return
      // 创建a标签,用于跳转至下载链接
      const tempLink = document.createElement("a");
      tempLink.style.display = "none";
      tempLink.href = url;
      tempLink.setAttribute("download", url);
      // 兼容:某些浏览器不支持HTML5的download属性
      if (typeof tempLink.download === "undefined") {
        tempLink.setAttribute("target", "_blank");
      }
      // 挂载a标签
      document.body.appendChild(tempLink);
      tempLink.click();
      document.body.removeChild(tempLink);
    }
    
    const init = () => {
      isMobile = !IsPC()
      addEvents()
    }
    onMounted(() => {
      init()
    })
    onUnmounted(() => {
      removeEvents()
    })
    
    defineExpose({
      clear, // 清空画布
      getSignPNGImgSrc, // 获取签名图片src地址 - 裁剪过的
      downLoadSignPNGImg, // 下载签名图片
      undo, // 撤销
      redo // 恢复
    })
    </script>
    
    <style lang="scss" scoped>
    .signCanvas {
      width: 100%;
      height: 100%;
    }
    </style>
    

    signCanvas/index.ts

    /*
     author:yangfeng
     date: 20231109
    */
    
    // 获取到文档的距离
    export function getToPageXY(e: MouseEvent | TouchEvent) {
      let touchE = e as TouchEvent;
      let mouseE = e as MouseEvent;
      if (touchE.changedTouches) {
        // 移动端
        return {
          x: touchE.changedTouches[0].pageX,
          y: touchE.changedTouches[0].pageY,
        };
      } else {
        return {
          x: mouseE.x || mouseE.pageX,
          y: mouseE.y || mouseE.pageY,
        };
      }
    }
    
    // 当前是否pc版本
    export function IsPC() {
      let userAgentInfo = navigator.userAgent;
      let Agents = [
        "Android",
        "iPhone",
        "SymbianOS",
        "Windows Phone",
        "iPad",
        "iPod",
      ];
      let flag = true;
      for (let v = 0; v < Agents.length; v++) {
        if (userAgentInfo.indexOf(Agents[v]) > 0) {
          flag = false;
          break;
        }
      }
      return flag;
    }
    
    /**
     * canvas 是否为空
     * @param canvas
     * @returns boolean
     */
    export function isCanvasBlank(canvas: HTMLCanvasElement) {
      var blank = document.createElement("canvas"); //系统获取一个空canvas对象
      blank.width = canvas.width;
      blank.height = canvas.height;
      return canvas.toDataURL() === blank.toDataURL(); //比较值相等则为空
    }
    
    /**
     * 校验签名图片是不是太小
     * @param imgSrc
     * @param size
     * @returns
     */
    export function validateImageSize(imgSrc: string, size = 10) {
      let img = new Image();
      img.src = imgSrc;
      return new Promise((resolve, reject) => {
        img.onload = (e) => {
          let target = (e.target || e.srcElement) as any;
          let width = target.width;
          let height = target.height;
          if (width < size && height < size) {
            reject({
              description: "签字太小了",
            });
          } else {
            resolve(true);
          }
        };
      });
    }
    
    interface IcropCanvasParams {
      canvas: HTMLCanvasElement; // 需要裁剪的canvas
      sx: number; // 裁剪开始点的x
      sy: number; // 裁剪开始点的y
      sw: number; // 裁剪宽
      sh: number; // 裁剪高
    }
    //
    /**
     * 裁剪 canvas 的指定区域
     * @param param0
     * @returns
     */
    export function cropCanvas({
      canvas, // 需要裁剪的canvas
      sx, // 裁剪开始点的x
      sy, // 裁剪开始点的y
      sw, // 裁剪宽
      sh, // 裁剪高
    }: IcropCanvasParams) {
      if (!canvas) return null;
      let newCanvas = document.createElement("canvas");
      let newCxt = newCanvas.getContext("2d");
      let gap = 4; // 签字留空隙
      newCanvas.width = sw + 2 * gap;
      newCanvas.height = sh + 2 * gap;
      let imgData = canvas
        .getContext("2d")!
        .getImageData(sx - gap, sy - gap, newCanvas.width, newCanvas.height);
      newCxt?.putImageData(imgData, 0, 0);
      return newCanvas;
    }
    
    export function getSignImgPngSrc({
      canvas, // 需要裁剪的canvas
      sx, // 裁剪开始点的x
      sy, // 裁剪开始点的y
      sw, // 裁剪宽
      sh, // 裁剪高
    }: IcropCanvasParams) {
      let newCanvas = cropCanvas({
        canvas, // 需要裁剪的canvas
        sx, // 裁剪开始点的x
        sy, // 裁剪开始点的y
        sw, // 裁剪宽
        sh, // 裁剪高
      });
      if (!newCanvas) return null;
      // if (this.isMobile && this.height > this.width) {
      //   let canvas1 = document.createElement('canvas'), cxt1 = canvas1.getContext('2d');
      //   canvas1.width = canvas.height;
      //   canvas1.height = canvas.width;
      //   let xpos = canvas1.width / 2, ypos = canvas1.height / 2;
      //   cxt1.translate(xpos, ypos);
      //   cxt1.rotate(-90 * Math.PI / 180);
      //   cxt1.translate(-xpos, -ypos);
      //   cxt1.drawImage(canvas, xpos - canvas.width / 2, ypos - canvas.height / 2);
      //
      //   return this.isCanvasBlank(canvas1) ? null : canvas1.toDataURL('image/png');
      // }
      return isCanvasBlank(newCanvas) ? null : newCanvas.toDataURL("image/png");
    }
    
    

    调用方式 index.vue:

    <template>
        <div class="wrap">
            <p>canvas 签名</p>
            <div>
                <el-button @click="canvasRef.clear()">清空</el-button>
                <el-button @click="canvasRef.undo()">撤销</el-button>
                <el-button @click="canvasRef.redo()">恢复</el-button>
                <el-button type="primary" @click="getImgSrc">获取签名图片</el-button>
                <el-button type="primary" @click="canvasRef.downLoadSignPNGImg()">下载签名图片</el-button>
            </div>
            <div class="canvasBox">
                <signCanvas ref="canvasRef" />
            </div>
            <div class="imgBox" v-show="data.imgSrc">
                <img :src="data.imgSrc" />
            </div>
        </div>
    </template>
    
    <script setup>
    import { ref, reactive, onMounted } from 'vue'
    import { ElMessage } from 'element-plus'
    import signCanvas from './components/signCanvas/index.vue'
    import { validateImageSize } from './components/signCanvas/index.ts'
    
    const canvasRef = ref()
    const data = reactive({
        imgSrc: '',
    })
    
    const getImgSrc = () => {
        data.imgSrc = '' // 清空
        let src = canvasRef.value.getSignPNGImgSrc()
        if (!src) return
    
        // 校验签名是否太小
        validateImageSize(src).then(res => {
            data.imgSrc = src
        }).catch(e => {
            ElMessage({
                showClose: true,
                message: e.description,
                type: 'warning',
            })
        })
    }
    
    </script>
    
    <style lang="scss" scoped>
    p{
        padding: 20px;
    }
    .wrap {
        width: 100%;
        text-align: center;
    }
    
    .canvasBox {
        // margin-top: 820px;
        margin: 20px auto;
        border: 1px solid #dddddd;
        width: 100%;
        height: 300px;
        box-sizing: border-box;
    }
    
    .imgBox {
        padding: 0;
        text-align: center;
    
        img {
            border: 1px solid #dddddd;
        }
    }
    </style>
    

    几个技巧:

    1.如何判断canvas是否为空?

    image.png
    答:创建一个同等宽高空的 canvas,t比较oDataURL()

    2.怎么校验签名生成的base64地址指向的图片大小满足要求?

    image.png
    答:放到img里面,获取其宽高即可判断。

    本文原创,若对你有帮助,请点个赞吧,若能打赏不胜感激,谢谢支持!
    本文地址:https://www.jianshu.com/p/15e55dba5521?v=1699536978071,转载请注明出处,谢谢。

    参考:
    https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toDataURL
    https://www.douyin.com/video/7171086219508452616

    相关文章

      网友评论

        本文标题:vue3+typescript+elementplus 手写ca

        本文链接:https://www.haomeiwen.com/subject/uemqwdtx.html