SVG 手写板实现指南:替代 Canvas 的轻量级方案

日常开发中,经常会遇到手写板的需求。对于大部分人来说使用 canvas 画布是最为方便的,而且也能很好的节省性能。这里在可汗学院学习的时候发现他们的答题手写用了 svg 的实现方法。这十分巧妙。不用考虑题目如何在 cavnas 画布上渲染了。

实现大致逻辑

通过鼠标的坐标绘制 svg 标签中的 path,然后加入到 svg 标签内。然后在擦除的时候根据(x,y) 以及宽高来计算出起点和终点坐标。判断是否 2 个数组的数据是否包含(leet code 上有一题合并数组与之类似),然后再选择是否删除对应的 path。

详细代码

这里采用类的方式,来完成代码的模块化。这十分有效的完成代码的解耦,使得代码层次更为清晰。

1
2
3
4
5
6
7
<h3>svg 手写板</h3>
<div id="drawing-area">
<svg id="drawing-svg"></svg>
</div>
<button class="erase-button" onclick="drawing.toggleEraseMode()">
Toggle Eraser
</button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#drawing-area {
border: 1px solid #ccc;
width: 800px;
height: 500px;
touch-action: none;
position: relative;
}
svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.erase-button {
margin-top: 10px;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
class HandwritingDrawing {
constructor(svgElement) {
this.svg = svgElement;
this.isDrawing = false;
this.isErasing = false;
this.lastX = 0;
this.lastY = 0;
this.currentPath = null;
this.eraseMode = false;
this.eraserRect = null;
this.initEvents();
}

initEvents() {
this.svg.addEventListener("mousedown", (e) => this.startDrawing(e));
this.svg.addEventListener("mousemove", (e) => this.draw(e));
this.svg.addEventListener("mouseup", () => this.stopDrawing());
this.svg.addEventListener("mouseleave", () => this.stopDrawing());

this.svg.addEventListener("touchstart", (e) => {
this.startDrawing(e);
e.preventDefault();
});
this.svg.addEventListener("touchmove", (e) => {
this.draw(e);
e.preventDefault();
});
this.svg.addEventListener("touchend", () => this.stopDrawing());
}

startDrawing(e) {
this.isDrawing = true;
const { offsetX, offsetY } = this.getCoordinates(e);
this.lastX = offsetX;
this.lastY = offsetY;
if (this.eraseMode) {
this.isErasing = true;
this.createEraserRectangle(offsetX, offsetY);
} else {
this.createNewPath(offsetX, offsetY);
}
}

draw(e) {
if (!this.isDrawing) return;
const { offsetX, offsetY } = this.getCoordinates(e);
if (this.eraseMode && this.isErasing) {
this.updateEraserRectangle(offsetX, offsetY);
} else if (!this.eraseMode && this.currentPath) {
this.updatePath(offsetX, offsetY);
}
}

stopDrawing() {
this.isDrawing = false;
if (this.eraseMode && this.isErasing && this.eraserRect) {
this.eraseIntersectingPaths();
this.svg.removeChild(this.eraserRect);
this.eraserRect = null;
this.isErasing = false;
}
this.currentPath = null;
}

createNewPath(x, y) {
this.currentPath = document.createElementNS(
"<http://www.w3.org/2000/svg>",
"path"
);
this.currentPath.setAttribute("stroke", "#000");
this.currentPath.setAttribute("stroke-width", "3");
this.currentPath.setAttribute("fill", "none");
this.currentPath.setAttribute("stroke-linecap", "round");
this.currentPath.setAttribute("stroke-linejoin", "round");
this.currentPath.setAttribute("d", `M${x},${y}`);
this.svg.appendChild(this.currentPath);
}

updatePath(x, y) {
const newD = this.currentPath.getAttribute("d") + ` L${x},${y}`;
this.currentPath.setAttribute("d", newD);
this.lastX = x;
this.lastY = y;
}

createEraserRectangle(x, y) {
this.eraserRect = document.createElementNS(
"<http://www.w3.org/2000/svg>",
"rect"
);
this.eraserRect.setAttribute("x", x);
this.eraserRect.setAttribute("y", y);
this.eraserRect.setAttribute("width", 0);
this.eraserRect.setAttribute("height", 0);
this.eraserRect.classList.add("eraser-rectangle");
this.svg.appendChild(this.eraserRect);
}

updateEraserRectangle(x, y) {
const width = x - this.lastX;
const height = y - this.lastY;
this.eraserRect.setAttribute("width", Math.abs(width));
this.eraserRect.setAttribute("height", Math.abs(height));
this.eraserRect.setAttribute("x", Math.min(x, this.lastX));
this.eraserRect.setAttribute("y", Math.min(y, this.lastY));
}

eraseIntersectingPaths() {
const rectX = parseFloat(this.eraserRect.getAttribute("x"));
const rectY = parseFloat(this.eraserRect.getAttribute("y"));
const rectWidth = parseFloat(this.eraserRect.getAttribute("width"));
const rectHeight = parseFloat(this.eraserRect.getAttribute("height"));
const elements = Array.from(this.svg.querySelectorAll("path"));
elements.forEach((element) => {
const bbox = element.getBBox();
if (
bbox.x < rectX + rectWidth &&
bbox.x + bbox.width > rectX &&
bbox.y < rectY + rectHeight &&
bbox.y + bbox.height > rectY
) {
this.svg.removeChild(element);
}
});
}

getCoordinates(e) {
if (e.touches) {
const touch = e.touches[0];
const rect = this.svg.getBoundingClientRect();
return {
offsetX: touch.clientX - rect.left,
offsetY: touch.clientY - rect.top,
};
} else {
return { offsetX: e.offsetX, offsetY: e.offsetY };
}
}

toggleEraseMode() {
this.eraseMode = !this.eraseMode;
}
}

const svgElement = document.getElementById("drawing-svg");
const drawing = new HandwritingDrawing(svgElement);

小结

上面的也说过这是个轻量的解决方案,所以他只适合一些简单的手写,书写的文字不多。如果书写内容过多的话,必定要生成大量的 path,过多的 dom 一方面会对网页照常卡顿,第二是每次添加 dom 会触发回流,也就是 dom 文档要重新排序。所以谨记这是一个轻量的解决方案。