一、概述
之前工作中遇到一个电子签名的需求,在后台管理系统产品验证模块需要绘制或上传产品检验报告单,报告单上实现电子签名功能。电子签名功能可以切换画笔颜色、画笔粗细、回退、重做、擦除和一键清空,支持以图片格式下载签名到本地存储,要求签名图片不能模糊,字体书写线条平滑,没有严重的锯齿感等。以上需求大致如下:
功能:自由绘制线条、选择画笔颜色、撤销、重做、擦除、画笔粗细、下载、重置
要求:线条平滑度、高清晰度
二、实践
2.1 签名
绘制签名核心思路,创建canvas标签,通过监听canvas元素的 mousedown
、mousemove
、mouseup
事件(在移动端需要监听 touchstart
、 touchmove
、 touchend
事件),捕获事件开始移动和移动过程中的坐标,然后使用Canvas API绘制字体线条。
下面是签名功能的代码:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>基于canvas实现签名</title>
<style>
/* 通配符 */
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
#canvas {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="text/javascript">
const cvs = document.getElementById('canvas');
cvs.width = innerWidth;
cvs.height = innerHeight;
const ctx = cvs.getContext('2d');
cvs.addEventListener('mousedown', (e) => {
this.isMouseDown = true; // 鼠标按下
ctx.moveTo(e.pageX, e.pageY); // 设置笔触起始位置
ctx.beginPath(); // 开始绘制路径,防止之前的点又重新画出来
});
cvs.addEventListener('mousemove', (e) => {
if (this.isMouseDown) {
ctx.lineTo(e.pageX, e.pageY); // 绘制直线
ctx.stroke(); // 绘制路径
}
});
cvs.addEventListener(mouseup', (e) => {
this.isMouseDown = false;
});
</script>
</body>
</html>
2.2 画笔颜色
要实现选择画笔颜色功能,我们需要添加一个input颜色选择器,然后通过该input标签onchange监听值的变化,从而修改画笔后续线条的颜色。这里就先把所有的html结构都写出来。
<body>
<canvas id="canvas"></canvas>
<div class="top-tools">
<span title="颜色选择器"><input type="color" onchange="changeColor(this.value)" /></span>
<span title="撤销" onclick="undo()">↶</span>
<span title="重做" onclick="redo()">↷</span>
<span title="橡皮檫">
<input type="checkbox" id="eraser" onchange="changeEraser(this)" />
<label for="eraser">🧽</label>
</span>
<span title="画笔粗细">
<input type="range" min="1" max="30" value="1" step="1" onchange="changeWidth(this.value)" />
</span>
<span title="保存" onclick="saveCanvas()">✓</span>
<span title="清空" onclick="clearCanvas()">↻</span>
</div>
</body>
实现修改画笔颜色功能函数
function changeColor(color) {
ctx.strokeStyle = color;
}
2.3 撤销和重做
定义两个数组,分别存储撤销和重做的ctx.getImageData()数据,在每次鼠标抬起事件后将最新的ctx.getImageData()存储到撤销数组中,当触撤销事件从撤销数组中取出加入重做数组并重新绘制。实现撤销和重做代码如下:
// 存储撤销和重做
let undoList=[],redoList=[];
cvs.addEventListener('mouseup', (e) => {
this.isMouseDown = false;
// 保存画布数据,用于撤销
undoList.push(ctx.getImageData(0, 0, cvs.width, cvs.height));
});
/**
* 撤销
*/
function undo() {
if (undoList.length > 0) {
redoList.push(undoList.pop());
reDraw();
}
}
/**
* 重做
*/
function redo() {
if (redoList.length > 0) {
undoList.push(redoList.pop());
reDraw();
}
}
/**
* 重绘
*/
function reDraw() {
if (undoList.length > 0) {
ctx.putImageData(undoList[undoList.length - 1], 0, 0);
} else {
ctx.clearRect(0, 0, cvs.width, cvs.height);
}
}
2.4 画笔粗细
要调用画笔粗细,向页面添加input[type="range"]滑动输入标签,通过标签的onchange属性监听滑块值的变化,去修改画笔粗细。
<input type="range" min="1" max="30" value="1" step="1" onchange="changeWidth(this.value)" />
/**
* 修改画笔粗细
* @param {number} value 线条粗细
*/
function changeWidth(value) {
ctx.lineWidth = value;
}
2.5 擦除和重置
通过设置ctx.globalCompositeOperation为'destination-out',仅保留画笔外的内容实现擦除功能。
/**
* 改变橡皮擦模式
* @param checkbox
*/
function changeEraser(checkbox) {
const label = document.querySelector(`label[for=${checkbox.id}]`);
if (checkbox?.checked) {
// 橡皮擦,仅保留画笔外的内容
ctx.globalCompositeOperation = 'destination-out';
label.classList.add('checked');
} else {
ctx.globalCompositeOperation = 'source-over';
label.classList.remove('checked');
}
}
清空重置画布
/**
* 清除画布
*/
function clearCanvas() {
if (!confirm('确定清空画布?')) return;
ctx.clearRect(0, 0, cvs.width, cvs.height);
}
2.6 下载保存
这通过a标签实现下载功能。
/**
* 下载保存
*/
function saveCanvas() {
const a = document.createElement('a');
a.href = cvs.toDataURL();
a.download = `sign_${Date.now()}.png`;
a.click();
a.remove();
}
2.7 优化
为了Canvas绘制的清晰度,要考虑到设备的显示倍率(DevicePixelRatio),涉及宽高需要乘于显示倍率。
将Canvas抗锯齿imageSmoothingQuality设为'high',把线头样式换成圆形,在一定程度能够保证坐标点之间线条连接的平滑度。
const cvs = document.getElementById('canvas');
cvs.width = innerWidth * devicePixelRatio;
cvs.height = innerHeight * devicePixelRatio;
const ctx = cvs.getContext('2d', {
willReadFrequently: true, // 开启频繁读取画布,浏览器会做一些优化
imageSmoothingQuality: 'high' // 设置抗锯齿质量
});
当我们canvas线条非常复杂时,可能就需要使用到缓存了,也还有其他Canvas优化方案,想到的同学欢迎写道评论区。
2.8 整体实现代码
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>基于canvas实现签名</title>
<style>
/* 通配符 */
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
#canvas {
width: 100%;
height: 100%;
}
.top-tools {
position: fixed;
top: 3px;
left: 50%;
z-index: 1;
padding: 4px;
background-color: #d8d8d8;
border-radius: 4px;
box-shadow:
0 0 0 1px rgb(0 0 0 / 5%),
0 2px 4px 1px rgb(0 0 0 / 9%);
transform: translateX(-50%);
/* IE 10+ */
-ms-user-select: none;
/* Firefox */
-moz-user-select: none;
/* Safari */
-webkit-user-select: none;
/* 禁止用户选中文本 */
user-select: none;
/* 背景模糊 */
backdrop-filter: blur(1px);
}
.top-tools span {
/* 重置样式 */
all: unset;
padding: 2px 5px;
margin: 0 3px;
font-size: 1.2em;
border-radius: 4px;
cursor: pointer;
}
.top-tools span:hover {
color: #3498db;
background-color: #c8c8c8;
}
.checked {
color: #3498db;
background-color: #c8c8c8;
}
input[type='color'] {
all: unset;
width: 2em;
height: 1.2em;
border-radius: 2px;
}
.refresh-icon {
/* 旋转动画 */
animation: spin 0.5s linear 1;
}
@keyframes spin {
from {
/* 初始角度 */
transform: rotate(0deg);
}
to {
/* 旋转360度 */
transform: rotate(360deg);
}
}
/* 隐藏复选框 */
#eraser {
display: none;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="top-tools">
<span title="颜色选择器"><input type="color" onchange="changeColor(this.value)" /></span>
<span title="撤销" onclick="undo()">↶</span>
<span title="重做" onclick="redo()">↷</span>
<span title="橡皮檫">
<input type="checkbox" id="eraser" onchange="changeEraser(this)" />
<label for="eraser">🧽</label>
</span>
<span title="画笔粗细">
<input type="range" min="1" max="30" value="1" step="1" onchange="changeWidth(this.value)" />
</span>
<span title="保存" onclick="saveCanvas()">✓</span>
<span title="清空" onclick="clearCanvas()">↻</span>
</div>
<script>
const cvs = document.getElementById('canvas');
cvs.width = innerWidth * devicePixelRatio;
cvs.height = innerHeight * devicePixelRatio;
const ctx = cvs.getContext('2d', {
willReadFrequently: true, // 开启频繁读取画布,浏览器会做一些优化
imageSmoothingQuality: 'high' // 设置抗锯齿质量
});
// 撤销重做
let undoList = [],
redoList = [];
ctx.lineWidth = ctx.lineWidth * devicePixelRatio;
ctx.lineCap = 'round'; // 设置线条末端样式
ctx.lineJoin = 'round'; // 设置线条连接处样式
cvs.addEventListener('mousedown', (e) => {
this.isMouseDown = true; // 鼠标按下
ctx.moveTo(e.pageX * devicePixelRatio, e.pageY * devicePixelRatio); // 设置笔触起始位置
ctx.beginPath(); // 开始绘制路径,防止之前的点又重新画出来
});
cvs.addEventListener('mousemove', (e) => {
if (this.isMouseDown) {
ctx.lineTo(e.pageX * devicePixelRatio, e.pageY * devicePixelRatio); // 绘制直线
ctx.stroke(); // 绘制路径
}
});
cvs.addEventListener('mouseup', (e) => {
this.isMouseDown = false;
// 保存画布数据,用于撤销
undoList.push(ctx.getImageData(0, 0, cvs.width, cvs.height));
});
/**
* 修改画笔颜色
*/
function changeColor(color) {
ctx.strokeStyle = color;
}
/**
* 修改画笔粗细
* @param {number} value 线条粗细
*/
function changeWidth(value) {
ctx.lineWidth = value * devicePixelRatio;
}
/**
* 撤销
*/
function undo() {
if (undoList.length > 0) {
redoList.push(undoList.pop());
reDraw();
}
}
/**
* 重做
*/
function redo() {
if (redoList.length > 0) {
undoList.push(redoList.pop());
reDraw();
}
}
/**
* 改变橡皮擦模式
* @param checkbox
*/
function changeEraser(checkbox) {
const label = document.querySelector(`label[for=${checkbox.id}]`);
if (checkbox?.checked) {
// 橡皮擦,仅保留画笔外的内容
ctx.globalCompositeOperation = 'destination-out';
label.classList.add('checked');
} else {
ctx.globalCompositeOperation = 'source-over';
label.classList.remove('checked');
}
}
/**
* 保存
*/
function saveCanvas() {
const a = document.createElement('a');
a.href = cvs.toDataURL();
a.download = `sign_${Date.now()}.png`;
a.click();
a.remove();
}
/**
* 清除画布
*/
function clearCanvas() {
if (!confirm('确定清空画布?')) return;
ctx.clearRect(0, 0, cvs.width, cvs.height);
}
/**
* 重绘
*/
function reDraw() {
if (undoList.length > 0) {
ctx.putImageData(undoList[undoList.length - 1], 0, 0);
} else {
ctx.clearRect(0, 0, cvs.width, cvs.height);
}
}
</script>
</body>
</html>
三、效果
四、参考文献
Unicode 符号表 - 所有 Unicode 字符及其代码都在一页上 (◕‿◕) SYMBL
Canvas系列-签字功能前言 好久没有分享文章了,由于刚换了一份工作去了鹅厂,每天忙于七七八八的事情,近些天也终于算 - 掘金