<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 = 12 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>