用fabric.js搞定電子簽名拖拽合成圖片的完整步驟
前言
上周組長(zhǎng)甩過來一個(gè)需求:“客戶要在合同圖片上簽電子簽名,還得能拖著簽名調(diào)整位置” 雖然是真的懵逼但也還是擠出一句 “好的,我看看”。查了一圈資料,發(fā)現(xiàn) fabric.js 這玩意兒簡(jiǎn)直是為這種 Canvas 交互量身定做的,空談理論不如上手實(shí)踐,今天就把實(shí)現(xiàn)過程掰開揉碎了講給大家。

先搭個(gè)基礎(chǔ)架子
任何前端功能實(shí)現(xiàn)的第一步都是搭建基礎(chǔ)環(huán)境。練習(xí)就直接用 CDN 引入 fabric.js了,畢竟不是每個(gè)項(xiàng)目都有閑工夫搞 npm 那套復(fù)雜流程。在 HTML 里放個(gè) Canvas 元素,再配個(gè)簽名板和幾個(gè)按鈕,基本布局就有了:
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
<div class="container">
<h3 style="padding-bottom: 5px;">合同圖片簽名區(qū)</h3>
<canvas id="canvas" width="800" height="400"></canvas>
<div class="signature-area">
<h4>電子簽名</h4>
<p class="tips">在下方畫布簽名,完成后點(diǎn)擊保存簽名</p>
<canvas id="signaturePad" width="800" height="150"></canvas>
<!-- 新增簽名操作區(qū) -->
<div class="signature-actions">
<button id="clearCurrentSignature">清空當(dāng)前簽名</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 部分簡(jiǎn)單美化一下,讓簽名板和主畫布分開顯示,按鈕排整齊點(diǎn),別讓用戶覺得我們的界面太潦草。
初始化 fabric 畫布
這一步是核心中的核心。初始化 fabric.Canvas 實(shí)例時(shí),最好加上 preserveObjectStacking 屬性,不然拖拽元素時(shí)層級(jí)會(huì)亂得讓人頭大。我吃過這方面的虧,大家一定要注意:
const canvas = new fabric.Canvas('canvas', {
preserveObjectStacking: true,
backgroundColor: '#fff'
});
初始化后可以先放張默認(rèn)圖片,讓用戶有個(gè)直觀感受。用 fabric.Image.fromURL 方法加載圖片,記得設(shè)置 selectable 為 true,這樣才能拖拽調(diào)整。
實(shí)現(xiàn)電子簽名功能
簽名功能用原生 Canvas 實(shí)現(xiàn)更靈活。給簽名板 Canvas 綁定 mousedown、mousemove 和 mouseup 事件,在 mousemove 時(shí)用 lineTo 繪制線條。這里有個(gè)小技巧,設(shè)置 lineCap 為 round 可以讓簽名更流暢自然。
保存簽名時(shí),把簽名板 Canvas 轉(zhuǎn)換成圖片對(duì)象,然后添加到 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('請(qǐng)先完成簽名再保存');
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,
// 可選設(shè)置
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;">合同圖片簽名區(qū)</h3>
<canvas id="canvas" width="800" height="400"></canvas>
<div class="signature-area">
<h4>電子簽名</h4>
<p class="tips">在下方畫布簽名,完成后點(diǎn)擊保存簽名</p>
<canvas id="signaturePad" width="800" height="150"></canvas>
<!-- 新增簽名操作區(qū) -->
<div class="signature-actions">
<button id="clearCurrentSignature">清空當(dāng)前簽名</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;
// 設(shè)置簽名線條樣式
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);
// 移動(dòng)端觸摸事件
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);
});
// 簽名繪制函數(shù)
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;
}
// 核心功能:清空當(dāng)前簽名
document.getElementById('clearCurrentSignature').addEventListener('click', () => {
// 先判斷是否有簽名內(nèi)容
const imageData = sigCtx.getImageData(0, 0, signaturePad.width, signaturePad.height);
const data = imageData.data;
let hasContent = false;
// 檢查畫布是否有內(nèi)容(跳過全透明像素)
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('簽名區(qū)為空,無需清空');
}
});
// 上傳圖片功能
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('請(qǐng)先完成簽名再保存');
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,
// 可選設(shè)置
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('請(qǐng)先點(diǎn)擊選中要?jiǎng)h除的簽名');
}
});
// 清空畫布
document.getElementById('clearCanvas').addEventListener('click', () => {
if (canvas.getObjects().length > 0 && confirm('確定要清空所有內(nèi)容嗎?')) {
canvas.clear();
}
});
// 初始化時(shí)加載示例圖片
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>
踩過的坑和優(yōu)化建議
測(cè)試時(shí)發(fā)現(xiàn),簽名圖片添加到畫布后有時(shí)會(huì)蓋住背景圖片。解決辦法是在添加背景圖片后調(diào)用 canvas.sendToBack (),把它放到最底層。另外,移動(dòng)端適配是個(gè)大問題。需要給 Canvas 添加 touch 事件監(jiān)聽,好在 fabric.js 有專門的 touch 事件處理,稍微配置一下就能用。如果用戶簽名失誤,最好加個(gè) “清除簽名” 按鈕,調(diào)用 canvas.remove (activeObject) 就能刪除當(dāng)前選中的簽名。
最后想說,作為程序員,我們每天都在和各種奇葩需求打交道。與其抱怨,不如像這次用 fabric.js 實(shí)現(xiàn)簽名合成功能一樣,把每次需求都當(dāng)成提升自己的機(jī)會(huì)。畢竟,解決問題的能力才是我們安身立命的根本。
到此這篇關(guān)于用fabric.js搞定電子簽名拖拽合成圖片的文章就介紹到這了,更多相關(guān)fabric.js電子簽名拖拽合成圖片內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
JavaScript如何通過userAgent判斷幾個(gè)常用瀏覽器詳解
userAgent 屬性是一個(gè)只讀的字符串,聲明了瀏覽器用于 HTTP 請(qǐng)求的用戶代理頭的值,這篇文章主要給大家介紹了關(guān)于JavaScript如何通過userAgent判斷幾個(gè)常用瀏覽器的相關(guān)資料,需要的朋友可以參考下2021-06-06
教你5分鐘學(xué)會(huì)用requirejs(必看篇)
下面小編就為大家?guī)硪黄棠?分鐘學(xué)會(huì)用requirejs(必看篇)。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-07-07
Javascript匿名函數(shù)的一種應(yīng)用 代碼封裝
說實(shí)話,對(duì)于js初學(xué)者的我來說。這個(gè)東西太嚇人了,在這些JS庫(kù)中,這個(gè)函數(shù)基本上把整個(gè)庫(kù)的所有代碼全括起來了,這種寫法完全超越了我的常識(shí)。2010-06-06
基于JavaScript實(shí)現(xiàn)跳轉(zhuǎn)提示頁(yè)面
這篇文章主要介紹了基于JavaScript實(shí)現(xiàn)跳轉(zhuǎn)提示頁(yè)面 的相關(guān)資料,需要的朋友可以參考下2016-09-09
JS小功能(onmouseover實(shí)現(xiàn)選擇月份)實(shí)例代碼
這篇文章主要介紹了onmouseover實(shí)現(xiàn)選擇月份實(shí)例代碼,有需要的朋友可以參考一下2013-11-11
echarts學(xué)習(xí)筆記之箱線圖的分析與繪制詳解
最近在學(xué)習(xí)echarts,所以下面這篇文章主要給大家介紹了關(guān)于echarts學(xué)習(xí)筆記之箱線圖的分析與繪制的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),需要的朋友可以參考借鑒,下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧。2017-11-11
ES6新特性二:Iterator(遍歷器)和for-of循環(huán)詳解
這篇文章主要介紹了ES6新特性二:Iterator(遍歷器)和for-of循環(huán),結(jié)合實(shí)例形式分析了ES6中Iterator(遍歷器)和for-of循環(huán)遍歷操作的相關(guān)實(shí)現(xiàn)技巧與注意事項(xiàng),需要的朋友可以參考下2017-04-04

