基于 canvas 的批注功能组件

2,820 阅读4分钟

项目上需要一个对当前页面批注并且可以添加文字的功能,最后生成一张图片附带到流程中。便于审批的领导明确提出对哪一点不满意。

demo演示

demo地址:github.com/dishui1238/…

该组件使用了 vux 组件库,也可换位其它组件库;html2canvas 脚本,用于将当前页面元素绘制到 canvas上;以及svg-sprite-loader用于加载 svg 文件,也可不使用。

实现思路

细细分析一番,发现有以下几个功能点:

  • 将当前页面绘制到 canvas 上
  • 实现画笔绘制
  • 实现撤销功能
  • 实现在指定位置添加文字功能
  • 实现文字的删除功能
  • 生成图片

下面就撸起袖子加油干!!!

实现过程

1. 将当前页面绘制到 canvas 上

我一拍脑门,这个简单,emmm。。。好像不是,还需要绘制当前元素及其所有子元素,以及所有元素样式。。。找到一个现成的脚本进行援助。

使用 html2canvas 脚本,该脚本允许你直接在用户浏览器截取页面或部分网页的“屏幕截屏”,通过读取 DOM 以及应用于元素的不同样式,将当前页面呈现为 canvas 图像,帮我完成了一半的工作。

  <button @click="handleClick">click</button>
    <div v-transfer-dom>
      <popup v-model="canvasShow" :should-rerender-on-show="true">
        <notation-canvas
          :canvasWidth="canvasWidth"
          :canvasHeight="canvasHeight"
          :imagesBase64="dataURL"
          @closeCanvas="closeCanvas"
        ></notation-canvas>
      </popup>
    </div>
  handleClick() {
      // 将 dom 及其子元素绘制到 canvas 上
      const capture = document.getElementById('capture');
      html2canvas(capture).then((canvas) => {
        this.dataURL = canvas.toDataURL('image/png');
        this.canvasWidth = capture.offsetWidth;
        this.canvasHeight = capture.offsetHeight;
        this.canvasShow = true;
      });
    },

将生成的 canvas 的高宽以及 dataURL 传递给我们接下来需要开发的组件 NotationCanvas,接下来我们就用这几个元素将页面绘制到 canvas 标签上,完成第一步的另一半工作。

首先准备 canvas 图形容器

 <canvas
    id="notation-canvas"
    ref="notationCanvas"
    :width="canvasWidthFull"
    :height="canvasHeightFull"
  >
    <p>您的浏览器暂不支持!</p>
  </canvas>

接下来,就需要展示 canvas 的威力了:

   drawImages() {
    const notationCanvas = this.$refs.notationCanvas;
    notationCanvas.style.width = this.canvasWidth + 'px';
    notationCanvas.style.height = this.canvasHeight + 'px';
    // 浏览器是否支持 canvas 标签
    if (notationCanvas.getContext) {
      // const canvasWidth = this.canvasWidth;
      // getContext() 方法可返回一个对象,该对象提供了用于在画布上绘图的方法和属性。
      const ctx = notationCanvas.getContext('2d'); // 获取上下文
      this.context = ctx;

      const canvasWidth = this.canvasWidth;
      this.canvasWidthFull = this.canvasWidth;
      this.canvasHeightFull = this.canvasHeight;

      const img = new Image();
      img.src = this.imagesBase64;
      img.onload = function () {
        ctx.drawImage(
          img,
          0,
          0,
          canvasWidth,
          (canvasWidth / img.width) * img.height // 保持长宽比
        );
      };
    }
  },

到此,我们的页面就绘制到 canvas 容器内了,可以干自己想干的事了。。。

2. 实现画笔绘制功能

首先,需要知道的是,浏览器有一个 TouchEvent 事件,该是对一系列事件的总称,包含 touchstart、touchend、touchmove 等,我们只需要用到这三个。

MDN上说: TouchEvent 是一类描述手指在触摸平面(触摸屏、触摸板等)的状态变化的事件。这类事件用于描述一个或多个触点,使开发者可以检测触点的移动,触点的增加和减少,等等。

每 个 Touch 对象代表一个触点; 每个触点都由其位置,大小,形状,压力大小,和目标 element 描述。 TouchList 对象代表多个触点的一个列表.

一个 TouchList,其会列出所有 当前 在与触摸表面接触的 Touch 对象,不管触摸点是否已经改变或其目标元素是在处于 touchstart 阶段。

  • touchstart: 当用户在触摸平面上放置了一个触点时触发,这个 TouchList 对象列出在此次事件中 新增加 的触点
  • touchend: 当一个触点被用户从触摸平面上移除(即用户的一个手指或手写笔离开触摸平面)时触发,changedTouches 是已经从触摸面的离开的触点的集合
  • touchmove: 当用户在触摸平面上移动触点时触发,列出和上次事件相比较,发生了变化的触点

现在我们在 canvas 上绑定上面的三个事件:

<canvas
    id="notation-canvas"
    ref="notationCanvas"
    :width="canvasWidthFull"
    :height="canvasHeightFull"
    @touchstart="touchstart($event)"
    @touchend="touchend($event)"
    @touchmove="touchmove($event)"
  >
    <p>您的浏览器暂不支持!</p>
  </canvas>

我们看一下,touchstart 这个事件中会给我们传递哪些有用的数据:

touchstart(e){ console.log(e) }

下面我们就使用这三个事件,记录用户在屏幕上绘制的点

具体思路:

  • 使用 linePoints 数组,存储所有点的坐标、颜色及类型(start/end/move)

记录开始点的坐标

touchstart(e) {
  // 开启时才画,而且画的时候把其他的隐藏
  if (this.isGraffiti) {
    this.visibleBtn = false;
    // 让move方法可用
    this.painting = true;
    // client是基于整个页面的坐标 offset是cavas距离顶部以及左边的距离
    // 计算 start 开始点的坐标,需要考虑滚动条
    const canvasX =
      e.changedTouches[0].clientX - e.target.parentNode.offsetLeft;
    const canvasY =
      e.changedTouches[0].clientY -
      e.target.parentNode.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
    this.setCanvasStyle(); // 设置canvas的配置样式
    this.context.beginPath(); // 开始一条路径
    this.context.moveTo(canvasX, canvasY); // 移动的起点
    this.linePoints.push({
      x: canvasX,
      y: canvasY,
      color: this.currentGraffitiColor,
      mode: 'start',
    });
  }
},

记录移动过程中和结束点的坐标

同样的,我们存储 move 过程中的坐标和 end 时的坐标,由于 move 过程中会产生很多的点,所以我们需要在 end 时,使用 pointsLength 数组记录当前路径的点的长度,使用数组的目的是因为不止有一条路径,可能有多条路径,需要记录每一条路径的长度,以便于后面撤销时知道需要删除哪些点。

 touchmove(e) {
  if (this.painting) {
    // 只有允许移动时调用
    const t = e.target;
    let canvasX = null;
    let canvasY = null;
    canvasX = e.changedTouches[0].clientX - t.parentNode.offsetLeft;
    canvasY =
      e.changedTouches[0].clientY -
      t.parentNode.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
    const ratio = this.getPixelRatio(this.context);
    // 连接到移动的位置并上色
    this.context.lineTo(canvasX * ratio, canvasY * ratio);
    this.context.stroke(); // 绘制已定义的路径
    this.linePoints.push({
      x: canvasX,
      y: canvasY,
      color: this.currentGraffitiColor,
      mode: 'move',
    });
  }
},

touchend(e) {
  if (this.isGraffiti) {
    this.visibleBtn = true;
    // 设置move时不可绘制
    this.painting = false;
    // 只有允许移动时调用
    const t = e.target;
    const canvasX = e.changedTouches[0].clientX - t.parentNode.offsetLeft;
    const canvasY =
      e.changedTouches[0].clientY -
      t.parentNode.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
    this.linePoints.push({
      x: canvasX,
      y: canvasY,
      color: this.currentGraffitiColor,
      mode: 'end',
    });
    // 存储
    this.pointsLength.push(this.drawImageHistory.length);
  }
},

linePoints 存储路径所有的点[start, ......, end, start,.......,end]

pointsLength 存储点的长度[length1, length2]

其中 linePoints 有多少组(从 start-move-end 是一组),pointsLength 长度就是多少,这里有一个对应关系,搞清楚这个,我们后面的撤销就很容易了。

3. 撤销功能

撤销时,从 linePoints 数组中删除 pointsLength[pointsLength.length - 1] 个点,剩下的点就是页面上留下的。这里实现方式是重新绘制剩余的路径。

    // 撤销涂鸦
    withdrawGraffiti() {
      const last = this.pointsLength.pop() || 0;
      const rest = this.pointsLength.length
        ? this.pointsLength[this.pointsLength.length - 1]
        : 0;
      // 撤销绘画
      this.linePoints.splice(rest, last - rest);
      this.redrawAll();
    },

    redrawAll() {
      const length = this.linePoints.length;

      const ctx = this.context;
      const width = this.canvasWidth;
      const linePoints = this.linePoints;
      const config = this.config;

      // 新建一个 canvas 作为缓存 canvas
      const tempCanvas = document.createElement('canvas');
      const tempCtx = tempCanvas.getContext('2d');

      const img = new Image();
      img.src = this.imagesBase64;
      img.onload = function () {
        const height = (width / img.width) * img.height;
        tempCanvas.width = width;
        tempCanvas.height = height; // 设置宽高
        tempCtx.drawImage(this, 0, 0, width, height);

        // 在给定的矩形内清除指定的像素
        ctx.clearRect(0, 0, width, height);

        ctx.drawImage(tempCanvas, 0, 0);

        for (let i = 0; i < length; i++) {
          const draw = linePoints[i];

          if (draw.mode === 'start') {
            ctx.lineWidth = config.lineWidth;
            ctx.shadowBlur = config.shadowBlur;
            ctx.shadowColor = draw.color;
            ctx.strokeStyle = draw.color;
            ctx.beginPath();
            ctx.moveTo(draw.x, draw.y);
          }
          if (draw.mode === 'move') {
            ctx.lineTo(draw.x, draw.y);
          }
          if (draw.mode === 'end') {
            ctx.stroke();
          }
        }
      };
    },

至此,我们就完成了一大半的功能

4.添加文字

文字添加,我这边是使用了一个 icon ,点击 icon 会显示一个遮罩,里面是 textarea ,其 value 绑定一个值,当确定添加时,将该值存储到 addTexts 数组中,并将文字的初始位置定义在页面中央。

页面:

  <!-- 添加文字 mask -->
    <div v-if="textMask" class="add-text-container" ref="addTextContainer">
      <div class="shadow-full"></div>
      <span class="cancel-add" @click="cancelAddText">取消</span>
      <span class="confirm-add" @click="confirmAddText">完成</span>
      <textarea
        v-model="addTextValue"
        :style="{color: currentTextColor}"
        class="text-area"
        wrap="hard"
        spellcheck="false"
        autocapitalize="off"
        autocomplete="off"
        autocorrect="off"
      ></textarea>

      <div class="graffiti-colors">
        <template v-for="color in graffitiColors">
          <div class="select-color" v-bind:key="color" @click="() => selectTextColor(color)">
            <div
              :class="{'color-item-active': currentTextColor === color}"
              class="color-item"
              :style="{background: color}"
            ></div>
          </div>
        </template>
      </div>
    </div>

逻辑:

// 确定添加字体
    confirmAddText() {
      this.textMask = false;
      this.visibleBtn = true;
      this.addTexts[this.textActiveIndex].textContent = this.addTextValue;
      this.addTextValue = '';

      const _this = this;
      this.$nextTick(function () {
        // 位置
        const textContents = _this.$refs.textContents;
        const t = textContents[_this.textActiveIndex];
        const content = t.children[0];
        const contentOffsetWidth = content.offsetWidth + 1; // 可能会出现差不到1换行
        const offsetWidth = t.parentNode.offsetWidth - 10;
        const offsetHeight = t.parentNode.offsetHeight;
        const width =
          (contentOffsetWidth > offsetWidth
            ? offsetWidth
            : contentOffsetWidth) + 1;
        // 添加会存在因为有滚动的情况,所以要进行处理,添加滚动的距离
        if (!t.style.left) {
          t.style.left = '50%';
          t.style.top =
            offsetHeight / 2 +
            (this.$refs.notationCanvasContent.scrollTop || 0) +
            'px';
          t.style.marginTop = '-50px';
          t.style.marginLeft = '-' + width / 2 + 'px';

          // 记录初始的点
        }
        t.style.width = width + 'px';
        t.style.color = _this.addTexts[_this.textActiveIndex].textColor;
      });
    },

然后就准备将添加的文字在 canvas 上展示出来。

首先准备一个文字容器,因为我们要记录文字移动的位置,我们同样需要使用 touchstart、touchmove、touchend 三个事件

  <!-- 文字容器 -->
   <template v-for="(item, index) in addTexts">
    <div
      v-bind:key="index"
      class="text-item"
      ref="textContents"
      @click="textClick(index)"
      @touchstart="textItemStart($event, index)"
      @touchmove="textItemMove($event, index)"
      @touchend="textItemEnd($event, index)"
    >
      {{item.textContent}}
      <!-- 该元素通过样式隐藏,为后面便于回去元素容器高宽 -->
      <span class="text-item-content">{{item.textContent}}</span>
    </div>
  </template>

我们需要再 touchstart 中标记文字开始移动,并且记录触摸点相对于元素的位置

 textItemStart(e, index) {
  // 标记文字开始移动
  this.addTexts[index].textItemMoveFlag = true;
  this.$refs.notationCanvasContent.style.overflow = 'hidden';
  
  // 记录触摸点相对于元素的位置
  this.addTexts[index].moveStartX =
      e.changedTouches[0].clientX - e.target.offsetLeft;
    this.addTexts[index].moveStartY =
      e.changedTouches[0].clientY -
      e.target.offsetTop +
      (this.$refs.notationCanvasContent.scrollTop || 0);
},

在 touchmove 过程中,需要对元素进行移动,这里通过移动点的坐标控制元素的绝对位置来达到文字容器移动的效果。在 touchend 事件中,标记文字移动结束。

  textItemMove(e, index) {
      if (this.addTexts[index].textItemMoveFlag) {
        this.visibleBtn = false;
        this.showRemoveText = true;
        const t = e.target;
        const content = t.children[0];
        // 文字内容的宽度
        const contentOffsetWidth = content.offsetWidth + 1; // 可能会出现差不到1换行,防止换行
        // 屏幕的宽度
        const offsetWidth = t.parentNode.offsetWidth - 10;
        // 宽度最大取到屏幕的宽度
        const width =
          contentOffsetWidth > offsetWidth ? offsetWidth : contentOffsetWidth;

        var moveWidth =
          e.changedTouches[0].clientX -
          this.addTexts[index].moveStartX -
          t.parentNode.offsetLeft;
        var moveHeight =
          e.changedTouches[0].clientY -
          this.addTexts[index].moveStartY -
          t.parentNode.offsetTop +
          (this.$refs.notationCanvasContent.scrollTop || 0);
        this.addTexts[index].moveEndX = moveWidth;
        this.addTexts[index].moveEndY = moveHeight;
       
        if (
          (moveWidth < 0 && -moveWidth >= width - 30) ||
          (moveWidth >= 0 && moveWidth >= offsetWidth - 30)
        ) {
          // 元素要留有 至少 30px高宽(展开想象的翅膀~~~) 否则回到最初位置
          t.style.left = '50%';
          t.style.top = '50%';
          t.style.marginTop = '-50px';
          t.style.marginLeft = '-' + width / 2 + 'px';
        } else {
          // 通过修改样式控制字体位置
          t.style.left = moveWidth + 'px';
          t.style.top = moveHeight + 'px';
          t.style.marginTop = 'auto';
          t.style.marginLeft = 'auto';
        }
      }
    },

这样,我们就完成了文字添加的功能,距离大功告成还差两步。

5.文字删除

文字删除需要实现的效果就是,将位子拖动到指定的位置松手就会执行删除,在日常应用中都见过类似的操作。

首先,这个删除的区域肯定是位于页面底部中间位置,并且只有在文字移动的过程中才展示

<div
    v-show="showRemoveText"
    class="remove-text"
    :class="{'remove-text-active': removeTextActive}"
    ref="removeText"
  >
    <div class="remove-icon">
      <svg class="icon">
        <use xlink:href="#icon-delete" />
      </svg>
    </div>
    <div v-if="removeTextActive" class="remove-tip">松手即可删除</div>
    <div v-else class="remove-tip">拖动到此处删除</div>
  </div>

然后,我们需要判断在移动的过程中,元素是否被移动到了这个区域内,我们修改一下 textItemMove 中的方法:

textItemMove(e, index) {
    if (this.addTexts[index].textItemMoveFlag) {
       
       ........
        
      // 判断是否要进行删除动作
      const removeTextEl = this.$refs.removeText; // 捕获元素的容器(执行删除)
      const x = removeTextEl.offsetLeft;
      const y = removeTextEl.offsetTop;
      const x1 = removeTextEl.offsetLeft + removeTextEl.offsetWidth;
      const y1 = removeTextEl.offsetTop + removeTextEl.offsetHeight;

      if (
        e.changedTouches[0].clientX >= x &&
        e.changedTouches[0].clientX <= x1 &&
        e.changedTouches[0].clientY >= y &&
        e.changedTouches[0].clientY <= y1
      ) {
        this.removeTextActive = true;
      } else {
        this.removeTextActive = false;
      }
    }
}

为了便于理解,我还展示了一下拙劣的画技,搞了一张图,大家多多包涵 🌝

其中那个绿色的点是中心点,当触摸点的位置(clientX, clientY)出现在删除容器中时,即 x < clientX < x1 并且 y < clientY < y1 时, 也可取等。

到这里,文字的部分也就完成了,但是但是,注意啦,我们并没有把文字绘制到 canvas 上,只是用了个 div 元素将其展示出来,这样的话如果我们将当前的 canvas 图片下载下来会发现其实并没有文字!!

所以,我们要在最后一步生成图片下载的时候,将文字一一绘制上去。至于为啥不提前绘制,因为我们要做文字的删除功能呀。

6.生成图片

confirm() {
  const notationCanvas = this.$refs.notationCanvas;
  this.context.shadowColor = 'rgba(238, 238, 238)';
 
  for (let i = 0, length = this.addTexts.length; i < length; i++) {
    const addText = this.addTexts[i];
    const addTextEl = this.$refs.textContents[i];
    const x = addTextEl.offsetLeft;
    const y = addTextEl.offsetTop + 13;

    const offsetWidth = addTextEl.parentNode.offsetWidth - 10;

    this.drawText(offsetWidth, addText, x, y);
  }

  // 得到图片
  var base64Img = notationCanvas.toDataURL('image/jpg');
  const link = document.createElement('a');
  link.href = base64Img;
  link.download = '拒绝原因.png';
  link.click();
  this.$emit('closeCanvas');
},

绘制文字的方法:

 drawText(contentWidth, addText, x, y) {
      // 画上文字
      this.context.font =
        "32px 'Helvetica Neue', -apple-system-font, sans-serif";

      // 设置颜色
      this.context.fillStyle = addText.textColor;
      // 设置水平对齐方式
      this.context.textAlign = 'start';
      // 设置垂直对齐方式
      this.context.textBaseline = 'middle';
      // 先判断一次总长度,如果长度比contentWidth小,就不处理,如果大就处理
      if (this.context.measureText(addText.textContent).width >= contentWidth) {
        // 分割文本为单个的字符
        const textChar = addText.textContent.split('');
        // 用于拼接
        let tmpText = '';
        const textRows = [];

        for (let i = 0, length = textChar.length; i < length; i++) {
          // 如果在结束之前,或者已经是最后了,那么会存在一种情况,还是会小于contentWidth,导致另一部分丢失
          if (
            this.context.measureText(tmpText).width / 2 >=
            contentWidth - 15
          ) {
            textRows.push(tmpText);
            tmpText = '';
          } else if (
            i === length - 1 &&
            this.context.measureText(tmpText).width / 2 < contentWidth - 15
          ) {
            tmpText += textChar[i];
            textRows.push(tmpText);
            tmpText = '';
          }
          tmpText += textChar[i];
        }

        for (let i = 0, length = textRows.length; i < length; i++) {
          // 绘制文字(参数:要写的字,x坐标,y坐标)
          this.context.fillText(textRows[i], x, (y + i * 24));
        }
      } else {
        this.context.fillText(addText.textContent, x, y);
      }
    },

问题

图片模糊

生成图片后会发现图片比较模糊,如下面图所示,通过查阅资料发现有一个

设备像素比的东西,就是用几个像素点宽度来渲染1个像素,我发现我使用的 chrome 浏览器的设备像素比是 3,可以通过 window.devicePixelRatio 获取设备像素比。也就是说 100 像素的值,在设备上展示的却是 300 像素,所以变得模糊。

解决方案就是创建由 devicePixelRatio 放大的图像,也就是说将 canvas 的宽高乘以相同的倍率,当然,所有使用到的坐标也要乘以相同的倍率。

下方是解决之后的图。

总结

当然,还有很多功能没有做,像文字的缩放、自定义绘制线条粗细等,因为我这边没有这种需求就没搞,后续有时间或许会完善一下,当然,你们有好的想法也可提供给我😉

强烈建议结合 demo 来看,因为该文档中只罗列了逻辑要点,即使跟着一步步敲,也不一定跑得出来哦,因为我省略了不少代码。