img

vue3 移动端使用canvas手写签名

2023-05-19 0条评论 779次阅读 JavaScript


效果与素材

移动端签名.gif

ico-sign-bg.png

父组件调用签名组件

<div class="trigger-area" @click="popShow.sign = true">手动签名</div>

<UDSignPopUp v-model:show="popShow.sign" @onConfirm="confirmSign" />
// 回调的结果
const confirmSign = (params) => {
    popShow.sign = false
  // base64上传后的图片链接
  console.log(`params===>`, params)
}

签名组件

<template>
    <van-popup v-model:show="show" position="bottom" safe-area-inset-bottom :style="horizontalStyle"
        :round="!isFullScreen" @opened="initCanvas" @closed="onClosed">
        <div :class="{ 'full-screen': isFullScreen }">
            <div class="sign-header">
                <span class="cancel" @click="close">取消</span>
                <span class="title">手动签名</span>
                <span class="confirm" @click="confirm">确定</span>
            </div>
            <div class="content" @touchmove="showSignPlaceholder = false">
                <img class="sign-bg" src="./img/ico-sign-bg.png" />
                <div v-if="showSignPlaceholder" class="sign-placeholder">
                    <span class="line1">在此签名</span>
                    <span class="line2">请使用正楷签字</span>
                </div>
                <canvas ref="canvasRef" class="sign-canvas"></canvas>
            </div>
            <div class="sign-footer">
                <div class="sign-btn" @click="onClickFullScreen">
                    <img class="loading" data-src="./img/ico-sign-full-screen.svg" />
                    <span>{{ isFullScreen ? '退出全屏' : '全屏' }}</span>
                </div>
                <div class="sign-btn" @click="resetSign">
                    <img class="loading" data-src="./img/ico-sign-refresh.svg" />
                    <span>重新签名</span>
                </div>
            </div>
        </div>
    </van-popup>
</template>

<script setup>
import { computed, ref, nextTick } from "vue";
import Draw from './draw';
import { uploadBase } from '@/apis/login';
import { Toast } from 'vant';
const props = defineProps({
    show: {
        type: Boolean,
        default: false,
    }
})
const isFullScreen = ref(false);
const showSignPlaceholder = ref(true);
const emits = defineEmits([
    'onClose', 'onConfirm', 'update:show'
])

//toRef update外部的值会readonly
const show = computed({
    get() {
        return props.show;
    },
    set(value) {
        emits('update:show', value);
    }
})


//计算全屏样式
const degree = computed(() => {
    //全屏为顺时针90度
    return isFullScreen.value ? 90 : 0;
}, {})
const canvasRef = ref();
const draw = ref(null);
const initCanvas = () => {
    if (!canvasRef.value) {
        return;
    }
    setTimeout(() => {
        draw.value = new Draw(canvasRef.value, -degree.value, {
            addEvent: !draw.value
        })
    }, 200);
}
const horizontalStyle = computed(() => {
    const d = document;
    const w = window.innerWidth || d.documentElement.clientWidth || d.body.clientWidth;
    const h = window.innerHeight || d.documentElement.clientHeight || d.body.clientHeight;
    let length = (h - w) / 2;
    let width = w;
    let height = h;
    switch (degree.value) {
        case -90:
            length = -length;
        case 90:
            width = h;
            height = w;
            break;
        default:
            length = 0;
    }

    nextTick(initCanvas);

    //只是一部分 还有内部的样式 需要.full-screen配合
    return isFullScreen.value && {
        transform: `rotate(${degree.value}deg) translate(${length}px,${length}px)`,
        width: `${width}px`,
        height: `${height}px`,
        transformOrigin: 'center center',
        //适配vantPopUp
        padding: '0',
        top: '0',
    };
})


const onClickFullScreen = (params) => {
    isFullScreen.value = !isFullScreen.value;
}
const resetSign = (params) => {
    showSignPlaceholder.value = true;
    draw.value && draw.value.clear();
}
const close = (params) => {
    show.value = false;
    emits('onClose')
}
//关闭弹出层且动画结束后触发
const onClosed = (params) => {
    isFullScreen.value = false
    resetSign();
}
const confirm = (params) => {
    let content = draw.value.getPNGImage();
  console.log(`content===>`, content) //data:image/png;base64,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
  //上传base64接口  
  uploadBase({ content }).then((res) => {
        if (res.data && res.data.url) {
            emits('onConfirm', res.data.url);
            close();
        } else {
            Toast("保存签名失败,请重试");
        }
    });
}
</script>

<style lang="less" scoped>
:root {
  --ud-blue: #6fb400;
}
.sign-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px 20px;
    border-bottom: 1px solid #E6E6E6;
    color: var(--ud-blue);

    .cancel,
    .confirm {
        font-size: 16px;
        font-family: PingFangSC-Regular, PingFang SC;
        font-weight: 400;
        line-height: 16px;
    }

    .cancel {
        color: rgba(0, 0, 0, 0.45);
    }

    .title {
        font-size: 18px;
        font-family: PingFangSC-Medium, PingFang SC;
        font-weight: 500;
        color: rgba(0, 0, 0, 0.85);
        line-height: 18px;
    }
}

.content {
    position: relative;
    margin: 23px 23px 0;
    color: var(--ud-blue);

    .sign-bg {
        display: block;
        width: 100%;
        height: 100%;
    }

    .sign-placeholder {
        position: absolute;
        top: 0;
        bottom: 0;
        width: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        flex-direction: column;

        .line1 {
            font-size: 30px;
            font-family: PingFangSC-Regular, PingFang SC;
            font-weight: 400;
            line-height: 30px;
            filter: opacity(0.35);
        }

        .line2 {
            margin-top: 18px;
            font-size: 14px;
            font-family: PingFangSC-Regular, PingFang SC;
            font-weight: 400;
            line-height: 14px;
            filter: opacity(0.44);
        }
    }

    .sign-canvas {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        cursor: crosshair;
    }
}

.sign-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 19px 24px 24px;
    color: var(--ud-blue);

    .sign-btn {
        display: flex;
        align-items: center;
        font-size: 14px;
        font-family: PingFangSC-Regular, PingFang SC;
        font-weight: 400;
        line-height: 14px;
    }

}

//全屏样式
.full-screen {
    display: flex;
    flex-direction: column;
    width: 100%;
    height: 100%;

    .content {
        flex: 1;

        overflow: hidden;
    }
}
</style>

draw.js

function Draw(canvas, degree, config = {}, contextConfig = {}) {
    if (!(this instanceof Draw)) {
        return new Draw(canvas, degree, config, contextConfig);
    }
    if (!canvas) {
        return;
    }

    let drawConfig = Object.assign({ addEvent: true }, config)
    //先去掉 重新获取宽高
    canvas.style.width = ``;
    canvas.style.height = ``;
    let { width, height } = window.getComputedStyle(canvas, null);
    width = width.replace('px', '');
    height = height.replace('px', '');

    this.canvas = canvas;
    this.context = canvas.getContext('2d');
    this.width = width;
    this.height = height;
    const context = this.context;

    // 根据设备像素比优化canvas绘图
    const devicePixelRatio = window.devicePixelRatio;
    if (devicePixelRatio) {
        canvas.style.width = `${width}px`;
        canvas.style.height = `${height}px`;
        canvas.height = height * devicePixelRatio;
        canvas.width = width * devicePixelRatio;
        context.scale(devicePixelRatio, devicePixelRatio);
    } else {
        canvas.width = width;
        canvas.height = height;
    }

    context.lineWidth = 6;
    context.strokeStyle = 'black';
    context.lineCap = 'round';
    context.lineJoin = 'round';
    Object.assign(context, config);

    // const canvasBoundRect = canvas.getBoundingClientRect();
    const point = {};
    const isMobile = /phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone/i.test(navigator.userAgent);
    // 移动端性能太弱, 去掉模糊以提高手写渲染速度
    if (!isMobile) {
        context.shadowBlur = 1;
        context.shadowColor = 'black';
    }
    let pressed = false;

    const paint = (signal) => {
        switch (signal) {
            case 1:
                context.beginPath();
                context.moveTo(point.x, point.y);
            case 2:
                context.lineTo(point.x, point.y);
                context.stroke();
                break;
            default:
        }
    };
    const create = signal => (e) => {
        e.preventDefault();
        if (signal === 1) {
            pressed = true;
        }
        if (signal === 1 || pressed) {
            e = isMobile ? e.touches[0] : e;
            const canvasBoundRect = canvas.getBoundingClientRect();
            point.x = e.clientX - canvasBoundRect.left;
            point.y = e.clientY - canvasBoundRect.top;
            paint(signal);
        }
    };
    const start = create(1);
    const move = create(2);
    const requestAnimationFrame = window.requestAnimationFrame;
    const optimizedMove = requestAnimationFrame ? (e) => {
        requestAnimationFrame(() => {
            move(e);
        });
    } : move;


    if (drawConfig.addEvent) {
        if (isMobile) {
            canvas.addEventListener('touchstart', start);
            canvas.addEventListener('touchmove', optimizedMove);
        } else {
            canvas.addEventListener('mousedown', start);
            canvas.addEventListener('mousemove', optimizedMove);
            ['mouseup', 'mouseleave'].forEach((event) => {
                canvas.addEventListener(event, () => {
                    pressed = false;
                });
            });
        }
    }

    // 重置画布坐标系
    if (typeof degree === 'number') {
        this.degree = degree;
        context.rotate((degree * Math.PI) / 180);
        switch (degree) {
            case -90:
                context.translate(-height, 0);
                break;
            case 90:
                context.translate(0, -width);
                break;
            case -180:
            case 180:
                context.translate(-width, -height);
                break;
            default:
        }
    }
}
Draw.prototype = {
    scale(width, height, canvas = this.canvas) {
        const w = canvas.width;
        const h = canvas.height;
        width = width || w;
        height = height || h;
        if (width !== w || height !== h) {
            const tmpCanvas = document.createElement('canvas');
            const tmpContext = tmpCanvas.getContext('2d');
            tmpCanvas.width = width;
            tmpCanvas.height = height;
            tmpContext.drawImage(canvas, 0, 0, w, h, 0, 0, width, height);
            canvas = tmpCanvas;
        }
        return canvas;
    },
    rotate(degree, image = this.canvas) {
        degree = ~~degree;
        if (degree !== 0) {
            const maxDegree = 180;
            const minDegree = -90;
            if (degree > maxDegree) {
                degree = maxDegree;
            } else if (degree < minDegree) {
                degree = minDegree;
            }

            const canvas = document.createElement('canvas');
            const context = canvas.getContext('2d');
            const height = image.height;
            const width = image.width;
            const degreePI = (degree * Math.PI) / 180;

            switch (degree) {
                // 逆时针旋转90°
                case -90:
                    canvas.width = height;
                    canvas.height = width;
                    context.rotate(degreePI);
                    context.drawImage(image, -width, 0);
                    break;
                // 顺时针旋转90°
                case 90:
                    canvas.width = height;
                    canvas.height = width;
                    context.rotate(degreePI);
                    context.drawImage(image, 0, -height);
                    break;
                // 顺时针旋转180°
                case 180:
                    canvas.width = width;
                    canvas.height = height;
                    context.rotate(degreePI);
                    context.drawImage(image, -width, -height);
                    break;
                default:
            }
            image = canvas;
        }
        return image;
    },
    getPNGImage(canvas = this.canvas) {
        return canvas.toDataURL('image/png');
    },
    getJPGImage(canvas = this.canvas) {
        return canvas.toDataURL('image/jpeg', 0.5);
    },
    downloadPNGImage(image) {
        const url = image.replace('image/png', 'image/octet-stream;Content-Disposition:attachment;filename=test.png');
        window.location.href = url;
    },
    dataURLtoBlob(dataURL) {
        const arr = dataURL.split(',');
        const mime = arr[0].match(/:(.*?);/)[1];
        const bStr = atob(arr[1]);
        let n = bStr.length;
        const u8arr = new Uint8Array(n);
        while (n--) {
            u8arr[n] = bStr.charCodeAt(n);
        }
        return new Blob([u8arr], { type: mime });
    },
    clear() {
        let width;
        let height;
        switch (this.degree) {
            case -90:
            case 90:
                width = this.height;
                height = this.width;
                break;
            default:
                width = this.width;
                height = this.height;
        }
        this.context.clearRect(0, 0, width, height);
    },
    upload(blob, url, success, failure) {
        const formData = new FormData();
        const xhr = new XMLHttpRequest();
        xhr.withCredentials = true;
        formData.append('image', blob, 'sign');

        xhr.open('POST', url, true);
        xhr.onload = () => {
            if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
                success(xhr.responseText);
            } else {
                failure();
            }
        };
        xhr.onerror = (e) => {
            if (typeof failure === 'function') {
                failure(e);
            } else {
                console.log(`upload img error: ${e}`);
            }
        };
        xhr.send(formData);
    },
};
export default Draw;
🏷️ #canvas#h5

💬 COMMENT


🦄 支持markdown语法

👋友