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

相关推荐
大橙子额2 小时前
【解决报错】Cannot assign to read only property ‘exports‘ of object ‘#<Object>‘
前端·javascript·vue.js
WooaiJava3 小时前
AI 智能助手项目面试技术要点总结(前端部分)
javascript·大模型·html5
Never_Satisfied3 小时前
在JavaScript / HTML中,关于querySelectorAll方法
开发语言·javascript·html
董世昌413 小时前
深度解析ES6 Set与Map:相同点、核心差异及实战选型
前端·javascript·es6
WeiXiao_Hyy4 小时前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
xjt_09015 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农5 小时前
Vue 2.3
前端·javascript·vue.js
辰风沐阳5 小时前
JavaScript 的宏任务和微任务
javascript
冰暮流星6 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_6 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js