WEB前端程序开发让前端飞web前端技术分享

vue3+typescript+elementplus 手写ca

2023-11-08  本文已影响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

上一篇下一篇

猜你喜欢

热点阅读