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

相关推荐
我命由我123455 分钟前
Element Plus 2.2.27 的单选框 Radio 组件,选中一个选项后,全部选项都变为选中状态
开发语言·前端·javascript·html·ecmascript·html5·js
weixin_4434785111 分钟前
flutter组件学习之卡片与列表
javascript·学习·flutter
moreen14 分钟前
Koa3.1.2 迁移, 持续更新中
javascript
qq_2113874728 分钟前
基于LangGraph多agent
开发语言·前端·javascript·agent·langgraph
liuyao_xianhui32 分钟前
优选算法_模拟_替换所有的‘?‘_C++
开发语言·javascript·数据结构·c++·算法·链表·动态规划
摸鱼仙人~44 分钟前
Vue Todo 实战练习教程(简略版)
前端·javascript·vue.js
FlyWIHTSKY1 小时前
Vue 3 单文件组件加载顺序详解
前端·javascript·vue.js
周万宁.FoBJ1 小时前
vue源码讲解之 reactive解析(仅proxy部分)
开发语言·javascript·ecmascript
乔磊1 小时前
我开发了一个 Ralph CLI
javascript
进击的尘埃1 小时前
Module Federation 2.0 共享策略翻车实录:版本协商、热更新与依赖冲突的排查工具链
javascript