用 fabric.js 搞定电子签名拖拽合成图片

首页 编程分享 JQUERY丨JS丨VUE 正文

wayhome在哪 转载 编程分享 2025-08-19 20:02:31

简介 上周组长甩过来一个需求:“客户要在合同图片上签电子签名,还得能拖着签名调整位置” 虽然是真的懵逼但也还是挤出一句 “好的,我看看”。查了一圈资料,发现 fabric.js 这玩意儿简直是为这种 Can


上周组长甩过来一个需求:“客户要在合同图片上签电子签名,还得能拖着签名调整位置” 虽然是真的懵逼但也还是挤出一句 “好的,我看看”。查了一圈资料,发现 fabric.js 这玩意儿简直是为这种 Canvas 交互量身定做的,空谈理论不如上手实践,今天就把实现过程掰开揉碎了讲给大家。

Fabric.js Javascript Library

先搭个基础架子

任何前端功能实现的第一步都是搭建基础环境。练习就直接用 CDN 引入 fabric.js了,毕竟不是每个项目都有闲工夫搞 npm 那套复杂流程。在 HTML 里放个 Canvas 元素,再配个签名板和几个按钮,基本布局就有了:

<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
<div class="container">
        <h3 style="padding-bottom: 5px;">合同图片签名区</h3>
        <canvas id="canvas" width="800" height="400"></canvas>
        
        <div class="signature-area">
            <h4>电子签名</h4>
            <p class="tips">在下方画布签名,完成后点击保存签名</p>
            <canvas id="signaturePad" width="800" height="150"></canvas>
            
            <!-- 新增签名操作区 -->
            <div class="signature-actions">
                <button id="clearCurrentSignature">清空当前签名</button>
                <button id="saveSignature">保存签名到画布</button>
                <button id="uploadImage">上传合同图片</button>
                <input type="file" id="fileInput" accept="image/*" style="display:none">
                <button id="removeSelected">删除选中签名</button>
                <button id="clearCanvas">清空画布</button>
            </div>
        </div>    
    </div>

CSS 部分简单美化一下,让签名板和主画布分开显示,按钮排整齐点,别让用户觉得我们的界面太潦草。

初始化 fabric 画布

这一步是核心中的核心。初始化 fabric.Canvas 实例时,最好加上 preserveObjectStacking 属性,不然拖拽元素时层级会乱得让人头大。我吃过这方面的亏,大家一定要注意:

const canvas = new fabric.Canvas('canvas', {
  preserveObjectStacking: true,
  backgroundColor: '#fff'
});

初始化后可以先放张默认图片,让用户有个直观感受。用 fabric.Image.fromURL 方法加载图片,记得设置 selectable 为 true,这样才能拖拽调整。

实现电子签名功能

签名功能用原生 Canvas 实现更灵活。给签名板 Canvas 绑定 mousedown、mousemove 和 mouseup 事件,在 mousemove 时用 lineTo 绘制线条。这里有个小技巧,设置 lineCap 为 round 可以让签名更流畅自然。

保存签名时,把签名板 Canvas 转换成图片对象,然后添加到 fabric 画布中:

document.getElementById('saveSignature').addEventListener('click', () => {
            // 检查签名是否为空
            const imageData = sigCtx.getImageData(0, 0, signaturePad.width, signaturePad.height);
            const data = imageData.data;
            let hasContent = false;
            
            for (let i = 0; i < data.length; i += 4) {
                if (data[i + 3] > 0) {
                    hasContent = true;
                    break;
                }
            }
            
            if (!hasContent) {
                alert('请先完成签名再保存');
                return;
            }
            
            const signatureImg = new Image();
            signatureImg.src = signaturePad.toDataURL('image/png');
            
            signatureImg.onload = function() {
                const fabricImg = new fabric.Image(signatureImg, {
                    left: 100,
                    top: 100,
                    opacity: 1,
                    // 启用缩放功能
                    lockScalingX: false,
                    lockScalingY: false,
                    lockUniScaling: false,
                    // 可选设置
                    minScaleLimit: 0.3, // 最小缩放比例
                    maxScaleLimit: 3,   // 最大缩放比例
                    isSignature: true
                });
                canvas.add(fabricImg);
                canvas.setActiveObject(fabricImg);
                sigCtx.clearRect(0, 0, signaturePad.width, signaturePad.height);
            };
        });

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>电子签名合成工具</title>
    <!-- 引入fabric.js -->
    <script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
    <style>
        *{
            margin:0;
            padding:0;
            box-sizing: border-box;
        }
        body{
            display:flex;
            justify-content: center;
            align-items: center;
        }
        .container {
            width: 90%;
            margin: 20px auto;
            padding: 20px;
            border: 1px solid #eee;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            display:flex;
            justify-content: center;
            align-items: center;
            flex-direction: column;
        }
        #canvas {
            border: 1px dashed #ccc;
            background-color: #f9f9f9;
        }
        .signature-area {
            margin: 20px 0;
        }
        #signaturePad {
            border: 1px solid #333;
            background-color: white;
        }
        .signature-actions {
            margin-top: 10px;
            display: flex;
            gap: 10px;
        }
        .btn-group {
            margin-top: 15px;
            display: flex;
            gap: 10px;
        }
        button {
            padding: 8px 16px;
            background-color: #4285f4;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background-color: #3367d6;
        }
        .tips {
            color: #666;
            font-size: 14px;
            margin-top: 5px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h3 style="padding-bottom: 5px;">合同图片签名区</h3>
        <canvas id="canvas" width="800" height="400"></canvas>
        
        <div class="signature-area">
            <h4>电子签名</h4>
            <p class="tips">在下方画布签名,完成后点击保存签名</p>
            <canvas id="signaturePad" width="800" height="150"></canvas>
            
            <!-- 新增签名操作区 -->
            <div class="signature-actions">
                <button id="clearCurrentSignature">清空当前签名</button>
                <button id="saveSignature">保存签名到画布</button>
                <button id="uploadImage">上传合同图片</button>
                <input type="file" id="fileInput" accept="image/*" style="display:none">
                <button id="removeSelected">删除选中签名</button>
                <button id="clearCanvas">清空画布</button>
            </div>
        </div>    
    </div>

    <script>
        // 初始化主画布
        const canvas = new fabric.Canvas('canvas', {
            preserveObjectStacking: true,
            backgroundColor: '#fff',
            enableTouchEvents: true
        });

        // 初始化签名板
        const signaturePad = document.getElementById('signaturePad');
        const sigCtx = signaturePad.getContext('2d');
        let isDrawing = false;

        // 设置签名线条样式
        sigCtx.lineWidth = 2;
        sigCtx.lineCap = 'round';
        sigCtx.strokeStyle = '#000';

        // 签名板事件绑定
        signaturePad.addEventListener('mousedown', startDrawing);
        signaturePad.addEventListener('mousemove', draw);
        signaturePad.addEventListener('mouseup', stopDrawing);
        signaturePad.addEventListener('mouseout', stopDrawing);

        // 移动端触摸事件
        signaturePad.addEventListener('touchstart', (e) => {
            const touch = e.touches[0];
            const mouseEvent = new MouseEvent('mousedown', {
                clientX: touch.clientX,
                clientY: touch.clientY
            });
            signaturePad.dispatchEvent(mouseEvent);
        });

        signaturePad.addEventListener('touchmove', (e) => {
            e.preventDefault();
            const touch = e.touches[0];
            const mouseEvent = new MouseEvent('mousemove', {
                clientX: touch.clientX,
                clientY: touch.clientY
            });
            signaturePad.dispatchEvent(mouseEvent);
        });

        signaturePad.addEventListener('touchend', () => {
            const mouseEvent = new MouseEvent('mouseup');
            signaturePad.dispatchEvent(mouseEvent);
        });

        // 签名绘制函数
        function startDrawing(e) {
            isDrawing = true;
            const rect = signaturePad.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            sigCtx.beginPath();
            sigCtx.moveTo(x, y);
        }

        function draw(e) {
            if (!isDrawing) return;
            const rect = signaturePad.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            sigCtx.lineTo(x, y);
            sigCtx.stroke();
        }

        function stopDrawing() {
            isDrawing = false;
        }

        // 核心功能:清空当前签名
        document.getElementById('clearCurrentSignature').addEventListener('click', () => {
            // 先判断是否有签名内容
            const imageData = sigCtx.getImageData(0, 0, signaturePad.width, signaturePad.height);
            const data = imageData.data;
            let hasContent = false;
            
            // 检查画布是否有内容(跳过全透明像素)
            for (let i = 0; i < data.length; i += 4) {
                if (data[i + 3] > 0) { // alpha通道大于0
                    hasContent = true;
                    break;
                }
            }
            
            if (hasContent) {
                sigCtx.clearRect(0, 0, signaturePad.width, signaturePad.height);
            } else {
                // 可以省略提示,也可以保留
                // alert('签名区为空,无需清空');
            }
        });

        // 上传图片功能
        document.getElementById('uploadImage').addEventListener('click', () => {
            document.getElementById('fileInput').click();
        });

        document.getElementById('fileInput').addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (!file) return;
            
            const reader = new FileReader();
            reader.onload = (event) => {
                fabric.Image.fromURL(event.target.result, (img) => {
                    const scale = Math.min(
                        canvas.width / img.width,
                        canvas.height / img.height,
                        1
                    );
                    img.scale(scale);
                    img.set({
                        left: (canvas.width - img.width * scale) / 2,
                        top: (canvas.height - img.height * scale) / 2,
                        selectable: false
                    });
                    canvas.add(img);
                    canvas.sendToBack(img);
                });
            };
            reader.readAsDataURL(file);
        });

        // 保存签名到主画布
        document.getElementById('saveSignature').addEventListener('click', () => {
            // 检查签名是否为空
            const imageData = sigCtx.getImageData(0, 0, signaturePad.width, signaturePad.height);
            const data = imageData.data;
            let hasContent = false;
            
            for (let i = 0; i < data.length; i += 4) {
                if (data[i + 3] > 0) {
                    hasContent = true;
                    break;
                }
            }
            
            if (!hasContent) {
                alert('请先完成签名再保存');
                return;
            }
            
            const signatureImg = new Image();
            signatureImg.src = signaturePad.toDataURL('image/png');
            
            signatureImg.onload = function() {
                const fabricImg = new fabric.Image(signatureImg, {
                    left: 100,
                    top: 100,
                    opacity: 1,
                    // 启用缩放功能
                    lockScalingX: false,
                    lockScalingY: false,
                    lockUniScaling: false,
                    // 可选设置
                    minScaleLimit: 0.3, // 最小缩放比例
                    maxScaleLimit: 3,   // 最大缩放比例
                    isSignature: true
                });
                canvas.add(fabricImg);
                canvas.setActiveObject(fabricImg);
                sigCtx.clearRect(0, 0, signaturePad.width, signaturePad.height);
            };
        });

        // 删除画布中选中的签名
        document.getElementById('removeSelected').addEventListener('click', () => {
            const activeObj = canvas.getActiveObject();
            if (activeObj) {
                canvas.remove(activeObj);
            } else {
                alert('请先点击选中要删除的签名');
            }
        });

        // 清空画布
        document.getElementById('clearCanvas').addEventListener('click', () => {
            if (canvas.getObjects().length > 0 && confirm('确定要清空所有内容吗?')) {
                canvas.clear();
            }
        });

        // 初始化时加载示例图片
        fabric.Image.fromURL('https://picsum.photos/800/600', (img) => {
            img.set({
                left: 0,
                top: 0,
                selectable: false
            });
            canvas.add(img);
            canvas.sendToBack(img);
        });
    </script>
</body>
</html>

踩过的坑和优化建议

测试时发现,签名图片添加到画布后有时会盖住背景图片。解决办法是在添加背景图片后调用 canvas.sendToBack (),把它放到最底层。另外,移动端适配是个大问题。需要给 Canvas 添加 touch 事件监听,好在 fabric.js 有专门的 touch 事件处理,稍微配置一下就能用。如果用户签名失误,最好加个 “清除签名” 按钮,调用 canvas.remove (activeObject) 就能删除当前选中的签名。

最后想说,作为程序员,我们每天都在和各种奇葩需求打交道。与其抱怨,不如像这次用 fabric.js 实现签名合成功能一样,把每次需求都当成提升自己的机会。毕竟,解决问题的能力才是我们安身立命的根本。

jym有其他方法欢迎评论区讨论

转载链接:https://juejin.cn/post/7539217631686475839


Tags:


本篇评论 —— 揽流光,涤眉霜,清露烈酒一口话苍茫。


    声明:参照站内规则,不文明言论将会删除,谢谢合作。


      最新评论




ABOUT ME

Blogger:袅袅牧童 | Arkin

Ido:PHP攻城狮

WeChat:nnmutong

Email:nnmutong@icloud.com

标签云