日常开发中,经常会遇到手写板的需求。对于大部分人来说使用 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 文档要重新排序。所以谨记这是一个轻量的解决方案。