SVG 手写板实现指南:替代 Canvas 的轻量级方案
日常开发中,经常会遇到手写板的需求。对于大部分人来说使用 canvas 画布是最为方便的,而且也能很好的节省性能。这里在可汗学院学习的时候发现他们的答题手写用了 svg 的实现方法。这十分巧妙。不用考虑题目如何在 cavnas 画布上渲染了。
实现大致逻辑
通过鼠标的坐标绘制 svg 标签中的 path,然后加入到 svg 标签内。然后在擦除的时候根据(x,y)
以及宽高来计算出起点和终点坐标。判断是否 2 个数组的数据是否包含(leet code 上有一题合并数组与之类似),然后再选择是否删除对应的 path。
详细代码
这里采用类的方式,来完成代码的模块化。这十分有效的完成代码的解耦,使得代码层次更为清晰。
<h3>svg 手写板</h3>
<div id="drawing-area">
<svg id="drawing-svg"></svg>
</div>
<button class="erase-button" onclick="drawing.toggleEraseMode()">
Toggle Eraser
</button>
#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;
}
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 文档要重新排序。所以谨记这是一个轻量的解决方案。