Newer
Older
xc-business-system / src / components / canvas / signCanvas / index.vue
lyg on 17 Apr 2024 7 KB 标准装置bug修改
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { IsPC, getSignImgPngSrc, getToPageXY } 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) => {
  const { 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 = () => {
  const canvas = canvasDomRef.value
  rectBoundary = {
    minX: canvas.width,
    minY: canvas.height,
    maxX: 0,
    maxY: 0,
  }
}

// #endregion 签名边界

// #region 撤销、恢复操作

const setUndoList = () => {
  const canvas = canvasDomRef.value
  const ctx = canvas.getContext('2d')
  undoList.push({
    imgData: ctx.getImageData(0, 0, canvas.width, canvas.height),
    rectBoundary: { // 记录此刻的签名边界
      ...rectBoundary,
    },
  })
}

// 清空画布
const clear = () => {
  const canvas = canvasDomRef.value
  if (!canvas) { return }
  const ctx = canvas.getContext('2d')
  // ctx.save();
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // ctx.restore();
  initRectBoundary() // 清空签名的边界
}

// 将历史记录绘制到画布中
const reDrawCanvas = () => {
  if (undoList.length) {
    const canvas = canvasDomRef.value
    const ctx = canvas.getContext('2d')
    const record = undoList[undoList.length - 1]
    rectBoundary = record.rectBoundary // 恢复此时的签名边界
    ctx.putImageData(record.imgData, 0, 0)
  }
  else { // 清空画布
    clear()
  }
}

// 撤销
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 clearUodoRedoList = () => {
  undoList = []
  redoList = []
}

// #endregion 撤销、恢复操作

// 转为在canvas画布中的像素
const getCanvasPx: (arg: { x: number; y: number }) => { x: number; y: number } = ({ x, y }) => {
  const canvas = canvasDomRef.value
  const { left, top } = canvas.getBoundingClientRect()
  return {
    x: x - left,
    y: y - top,
  }
}

// resize
const resizeHandle = () => {
  clearTimeout(Number(tickTimer))
  tickTimer = setTimeout(() => {
    clear()
    clearUodoRedoList() // 每次reize 清空历史记录 - 因为宽高改变恢复了也是变形的
    const canvas = canvasDomRef.value
    if (!canvas) { return }
    const parentNode = canvas.parentNode
    const wd = parentNode.clientWidth
    const 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
  const canvas = canvasDomRef.value
  const { x, y } = getCanvasPx(getToPageXY(e))
  const ctx = canvas.getContext('2d')
  ctx.beginPath()
  ctx.moveTo(x, y)
  setRectBoundary(x, y) // 存储签名的最大使用区域
}

// mousemove
const moveHandle = (e: MouseEvent) => {
  if (!data.isMouseDown) { return }
  const canvas = canvasDomRef.value
  const { x, y } = getCanvasPx(getToPageXY(e))
  const 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 = () => {
  const 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))
  const 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 = () => {
  const canvas = canvasDomRef.value
  const { 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 = () => {
  const 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>

<template>
  <canvas ref="canvasDomRef" class="signCanvas" />
</template>

<style lang="scss" scoped>
.signCanvas {
  width: 100%;
  height: 100%;
  touch-action: none;
  // background-color: #fff;
}
</style>