vue3 实现签名组件

1,021 阅读5分钟

工作上需要实现电子签名组件,目前项目的技术栈是 vue3 + element plus,实现的功能有 画笔、橡皮、清空。

用到了鼠标事件、触摸事件,在这里先回顾一下相关的知识点。

鼠标事件相关

鼠标事件

鼠标事件触发条件
mouseover当鼠标经过被选元素和被选元素的子元素时都会触发mouseover事件,对应mouseout事件
mouseout无论鼠标离开被选元素还是被选元素的子元素都会触发
mousemove当鼠标移入被选元素内后,任意移动一个像素点都会触发
mouseup无论鼠标离开被选元素还是被选元素的子元素都会触发
mousedown鼠标按下触发
mouseenter不会冒泡,当鼠标经过被选元素才会触发,经过子盒子不触发,对应mouseleave事件
mouseleave不会冒泡,鼠标离开元素上会触发,离开子盒子不触发

事件属性

获取某个dom节点,对节点添加监听事件时,一般用的最多的就是 event 或者 event.target 下相关的属性。

const canvasDom = document.getElementById('canvas');
canvasDom.onmousemove = function (e) {
  console.log(e) // event 事件
  console.log(e.target) // 触发事件的对象 (某个DOM元素) 的引用
}

event

鼠标本身的位置相关的属性

  • pageX/Y

    触发点相对文档区域左上角距离,会随着页面滚动而变化

    兼容性:除IE6/7/8不支持外,其余浏览器均支持

  • clientX/Y

    触发点相对浏览器可视区域左上角距离,不随页面滚动而变化

    兼容性:所有浏览器均支持

  • screenX/Y

    触发点相对显示器屏幕左上角的距离,不随页面滚动而改动

    兼容性:所有浏览器均支持

  • offsetX/Y

    触发点相对被触发dom的左上角距离,不过左上角基准点在不同浏览器中有区别,其中在IE中以内容区左上角为基准点不包括边框,如果触发点在边框上会返回负值,而chrome中以边框左上角为基准点。

    兼容性:IE所有版本,chrome,Safari均完美支持,Firefox不支持

  • layerX/Y

    触发点相对被触发dom左上角的距离,数值与offsetX/Y相同,这个变量就是firefox用来替代offsetX/Y的,基准点为边框左上角,但是有个条件就是,被触发的dom需要设置为position:relative或者position:absolute,否则会返回相对html文档区域左上角的距离。

    兼容性:IE6/7/8不支持,opera不支持,IE9/10和Chrome、Safari均支持

event.target

触发事件的对象 (某个DOM元素) 的引用

  • offsetHeight/offsetWidth:元素自身可见高度 + padding + border + 滚动条(17),不包含margin,不包括溢出不可见部分的高度。

  • clientHeight/clientWidth:元素自身可见高度 + padding,不包含border与margin,不包括溢出不可见部分的高度。

  • scrollHeight/scrollWidth:只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容,包含width和padding,不包含border与margin

  • scrollTop/scrollLeft:获取元素滚动后的距离文档顶部的距离,也就是滚动条滚动的距离。

  • clientTop:获取元素边框的厚度,也就是border的宽度。 有个公式:target.scrollTop + target.offsetHeight === target.scrollHeight用于判断滚动条是否滚动到底。

touch事件

事件类型

  • touchstart:触摸开始的时候触发
  • touchmove : 手指在屏幕上滑动的时候触发
  • touchend: 触摸结束的时候触发
  • touchcancel:触摸时由于某些原因被中断时触发

事件属性

当触发事件的时候会生成一个event对象,以下是对象的属性列表

  • touches:屏幕上所有触摸点的信息
  • targetTouches:目标区域上所有触摸点的信息
  • changedTouches:当前事件触摸点的信息

区别:

  • 第一根手指触摸屏幕,三个事件获取到的信息是相同的。
  • 第二根手指触摸屏幕,touches会获取两个触摸点的信息,如果触摸点是在同一个目标区域上的targetTouches也是获取到两个触摸点信息,而chengedTouched只保存第二个信息点。
  • 若同时两根手指触摸屏幕,changedTouches会保存两个触摸点信息。
  • 第一个触摸点和最后一个触摸点离开的时候,只有changedTouches才会保存离开的触摸点信息。

属性参数

// 获取event.changedTouches 输出
{
    clientX: 603.6799926757812 // 返回触摸点相对于浏览器视口左边缘的X坐标,不包括任何滚动偏移。
    clientY: 932.9600219726562 // 返回触摸点相对于浏览器视口上边缘的Y坐标,不包括任何滚动偏移。
    force: 1 // 压力大小,是从 0.0(无压力)到 1.0(最大压力)的浮点数
    identifier: 0 // 一次触摸动作的唯一标识符
    pageX: 603.6799926757812 // 返回触摸点相对于文档左边缘的X坐标。与clientX此不同,此值包括水平滚动偏移
    pageY: 932.9600219726562 // 返回触摸点相对于文档顶部的Y坐标。与clientY,此值不同,包括垂直滚动偏移
    radiusX: 30.053333282470703 // 返回椭圆的X半径,该半径最接近地限定与屏幕的接触区域。该值的大小与像素相同screenX。
    radiusY: 30.053333282470703 //返回椭圆的Y半径,该半径最接近地限定与屏幕的接触区域。该值的大小与像素相同screenY。
    rotationAngle: 0 // 它是这样一个角度值:由radiusX 和 radiusY 描述的正方向的椭圆,需要通过顺时针旋转这个角度值,才能最精确地覆盖住用户和触摸平面的接触面
    screenX: 447 // 返回触摸点相对于屏幕左边缘的X坐标。
    screenY: 527 // 返回触摸点相对于屏幕上边缘的Y坐标。
    target: body // 此次触摸事件的目标element
}

代码

<template>
    <div class="canvas-dom">
        <header>
            <el-radio-group v-model="props.tool" class="mr-4">
                <el-radio label="paint">画笔</el-radio>
                <el-radio label="eraser">橡皮</el-radio>
            </el-radio-group>
            <el-button
                type="danger"
                round
                class="mr-4"
                @click="clearSign"
            >
                清空签名
            </el-button>

            <div v-show="(props.tool === 'eraser')" class="eraser-option">
                <label>形状:</label>
                <el-select v-model="shape" class="mr-4">
                    <el-option 
                        v-for="item in ['rect', 'circle']"
                        :key="item"
                        :label="item"
                        :value="item"
                    />
                </el-select>
                <label>大小(px):</label>
                <el-input-number v-model="size" :min="1" />
            </div>
        </header>
        <canvas
            ref="canvas"
            height="200"
            width="500"
            @mousedown="onEventStart"
            @mousemove.stop.prevent="onEventMove"
            @mouseup="onEventEnd"
            @touchstart="onEventStart"
            @touchmove.stop.prevent="onEventMove"
            @touchend="onEventEnd"
        >
        </canvas>
    </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { type VComponent } from './componentList'
import { useFormStore } from '@/store'

const props = defineProps<{
    tool: string
}>()

// 橡皮的形状
const shape = ref<'rect'| 'circle'>('rect')
// 橡皮的大小
const size = ref(10) 

const canvas = ref()
let ctx: CanvasRenderingContext2D

// 正在绘制中,用来控制 move 和 end 事件
let painting = false

// 获取触发点相对被触发dom的左、上角距离
const _getOffset = (event: MouseEvent|TouchEvent) => {
    let offset: [number, number]
    if ((event as MouseEvent).offsetX) {
        // pc端
        const { offsetX, offsetY } = event as MouseEvent
        offset = [offsetX, offsetY]
    } else {
        // 移动端
        const { top, left } = canvas.value.getBoundingClientRect()       
        const offsetX = (event as TouchEvent).touches[0].clientX - left
        const offsetY = (event as TouchEvent).touches[0].clientY - top
        offset = [offsetX, offsetY]
    }

    return offset
}

// 绘制起点
let startX = 0, startY = 0

// 鼠标/触摸 按下时,保存 触发点相对被触发dom的左、上 距离 
const onEventStart = (event: MouseEvent|TouchEvent) => {
    [startX, startY] = _getOffset(event)
    painting = true
}

const onEventMove = (event: MouseEvent|TouchEvent) => {
    if (painting) {
        // 鼠标/触摸 移动时,保存 移动点相对 被触发dom的左、上 距离 
        const [endX, endY] = _getOffset(event)

        if (props.value.tool === 'paint') {
            paint(startX, startY, endX, endY, ctx)
        } else {
            eraser(startX, startY, endX, endY, ctx, size.value, shape.value)
        }

        // 每次绘制 或 清除结束后,起点要重置为上次的终点
        startX = endX
        startY = endY
    }
}

const onEventEnd = () => {
    if (painting) {
        painting = false // 停止绘制
    }
}

onMounted(() => {
    ctx = canvas.value.getContext('2d') as CanvasRenderingContext2D
})

const clearSign = () => {
    ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
}

// canvas 画图
function paint(startX: number, startY: number, endX: number, endY: number, ctx: CanvasRenderingContext2D) {
    ctx.beginPath()
    ctx.globalAlpha = 1
    ctx.lineWidth = 2
    ctx.strokeStyle = '#000'
    ctx.moveTo(startX, startY)
    ctx.lineTo(endX, endY)
    ctx.closePath()
    ctx.stroke()
}

// 橡皮
function eraser(
    startX: number, 
    startY: number, 
    endX: number, 
    endY: number, 
    ctx: CanvasRenderingContext2D, 
    size: number, 
    shape: 'rect'| 'circle',
) {
    ctx.beginPath()
    ctx.globalAlpha = 1
    switch (shape) {
        case 'rect':
            ctx.lineWidth = size
            ctx.strokeStyle = '#fff'
            ctx.moveTo(startX, startY)
            ctx.lineTo(endX, endY)
            ctx.closePath()
            ctx.stroke()
            break
        case 'circle':
            ctx.fillStyle = '#fff'
            ctx.arc(startX, startY, size, 0, 2 * Math.PI)
            ctx.fill()
            break
    }
}
</script>

<style scoped lang="scss">
.canvas-dom {
    width: 100%;

    canvas {
        border: 1px solid #e6e6e6;
    }

    header {
        width: 100%;
        margin: 8px;
        display: flex;
        flex-flow: row nowrap;
        align-items: center;

        .eraser-option {
            display: flex;

            label {
                white-space: nowrap;
            }
        }
    }
}
</style>

演示

Dec-01-2022 23-21-26

reference

github.com/woai3c/2017…

juejin.cn/post/684490…