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

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

Fabric.js Javascript Library

先搭个基础架子

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

xml 复制代码
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
javascript 复制代码
<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 属性,不然拖拽元素时层级会乱得让人头大。我吃过这方面的亏,大家一定要注意:

php 复制代码
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 画布中:

ini 复制代码
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);
            };
        });

完整代码

xml 复制代码
<!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 实现签名合成功能一样,把每次需求都当成提升自己的机会。毕竟,解决问题的能力才是我们安身立命的根本。

相关推荐
一只一只妖21 小时前
突发奇想,还未实践,在Vben5的Antd模式下,将表单从「JS 配置化」改写成「模板可视化」形式(豆包版)
前端·javascript·vue.js
悟能不能悟1 天前
js闭包问题
开发语言·前端·javascript
秋秋_瑶瑶1 天前
vue-amap组件呈现的效果图如何截图
前端·javascript·vue-amap
LFly_ice1 天前
学习React-9-useSyncExternalStore
javascript·学习·react.js
gnip1 天前
js上下文
前端·javascript
中草药z1 天前
【Stream API】高效简化集合处理
java·前端·javascript·stream·parallelstream·并行流
世伟爱吗喽1 天前
threejs入门学习日记
前端·javascript·three.js
F2E_Zhangmo1 天前
基于cornerstone3D的dicom影像浏览器 第五章 在Displayer四个角落显示信息
开发语言·前端·javascript
小浣熊喜欢揍臭臭1 天前
react+umi项目如何添加electron的功能
javascript·electron·react
乖女子@@@1 天前
React笔记_组件之间进行数据传递
javascript·笔记·react.js