实现 Web 端自定义截屏

发布于 2022-10-17 19:59:34 字数 32274 浏览 156 评论 0

前言

当客户在使用我们的产品过程中,遇到问题需要向我们反馈时,如果用纯文字的形式描述,我们很难懂客户的意思,要是能配上问题截图,这样我们就能很清楚的知道客户的问题了。

那么,我们就需要为我们的产品实现一个自定义截屏的功能,用户点完 "截图" 按钮后,框选任意区域,随后在框选的区域内进行圈选、画箭头、马赛克、直线、打字等操作,做完操作后用户可以选择保存框选区域的内容到本地或者直接发送给我们。

聪明的开发者可能已经猜到了,这是 QQ / 微信的截图功能,我的开源项目正好做到了截图功能,在做之前我找了很多资料,没有发现 web 端有这种东西存在,于是我就决定参照 QQ 的截图自己实现一个并做成插件供大家使用。

本文就跟大家分享下我在做这个 "自定义截屏功能" 时的实现思路以及过程,欢迎各位感兴趣的开发者阅读本文。

运行结果视频:实现 web 端自定义截屏

写在前面

本文插件的写法采用的是 Vue3 的 compositionAPI,如果对其不了解的开发者请移步我的另一篇文章:使用 Vue3 的 CompositionAPI 来优化代码量

实现思路

我们先来看下 QQ 的截屏流程,进而分析它是怎么实现的。

截屏流程分析

我们先来分析下,截屏时的具体流程。

点击截屏按钮后,我们会发现页面上所有动态效果都静止不动了,如下所示。

随后,我们按住鼠标左键进行拖动,屏幕上会出现黑色蒙板,鼠标的拖动区域会出现镂空效果,如下所示。

完成拖拽后,框选区域的下方会出现工具栏,里面有框选、圈选、箭头、直线、画笔等工具,如下图所示。

点击工具栏中任意一个图标,会出现画笔选择区域,在这里可以选择画笔大小、颜色如下所示。

随后,我们在框选的区域内进行拖拽就会绘制出对应的图形,如下所示。

最后,点击截图工具栏的下载图标即可将图片保存至本地,或者点击对号图片会自动粘贴到聊天输入框,如下所示。

截屏实现思路

通过上述截屏流程,我们便得到了下述实现思路:

  • 获取当前可视区域的内容,将其存储起来
  • 为整个 cnavas 画布绘制蒙层
  • 在获取到的内容中进行拖拽,绘制镂空选区
  • 选择截图工具栏的工具,选择画笔大小等信息
  • 在选区内拖拽绘制对应的图形
  • 将选区内的内容转换为图片

实现过程

我们分析出了实现思路,接下来我们将上述思路逐一进行实现。

获取当前可视区域内容

当点击截图按钮后,我们需要获取整个可视区域的内容,后续所有的操作都是在获取的内容上进行的,在 web 端我们可以使用 canvas 来实现这些操作。

那么,我们就需要先将 body 区域的内容转换为 canvas,如果要从零开始实现这个转换,有点复杂而且工作量很大。

还好在前端社区种有个开源库叫 html2canvas 可以实现将指定 dom 转换为 canvas,我们就采用这个库来实现我们的转换。

接下来,我们来看下具体实现过程:

新建一个名为 screen-short.vue 的文件,用于承载我们的整个截图组件。

首先我们需要一个 canvas 容器来显示转换后的可视区域内容

<template>
  <teleport to="body">
    <!--截图区域-->
    <canvas
     
      :width="screenShortWidth"
      :height="screenShortHeight"
      ref="screenShortController"
    ></canvas>
  </teleport>
</template>

此处只展示了部分代码,完整代码请移步:screen-short.vue

在组件挂载时,调用 html2canvas 提供的方法,将 body 中的内容转换为 canvas,存储起来。

import html2canvas from "html2canvas";
import InitData from "@/module/main-entrance/InitData";

export default class EventMonitoring {
  private readonly data: InitData;
  
  private screenShortController: Ref<HTMLCanvasElement | null>;
  
  private screenShortImageController: HTMLCanvasElement | undefined;
  
  constructor(props: Record<string, any>, context: SetupContext<any>) {
    this.data = new InitData();
    
    this.screenShortController = this.data.getScreenShortController();
    
    onMounted(() => {
      this.data.setScreenShortInfo(window.innerWidth, window.innerHeight);
      
      html2canvas(document.body, {}).then(canvas => {
        if (this.screenShortController.value == null) return;
        this.screenShortImageController = canvas;
      })
    })
  }
}

此处只展示了部分代码,完整代码请移步:EventMonitoring.ts

为 canvas 画布绘制蒙层

我们拿到了转换后的 dom 后,我们就需要绘制一个透明度为 0.6 的黑色蒙层,告知用户你现在处于截屏区域选区状态。

具体实现过程如下:

创建 DrawMasking.ts 文件,蒙层的绘制逻辑在此文件中实现,代码如下。

export function drawMasking(context: CanvasRenderingContext2D) {
  
  context.clearRect(0, 0, window.innerWidth, window.innerHeight);
  
  context.save();
  context.fillStyle = "rgba(0, 0, 0, .6)";
  context.fillRect(0, 0, window.innerWidth, window.innerHeight);
  
  context.restore();
}

注释已经写的很详细了,对上述 API 不懂的开发者请移步:clearRectsavefillStylefillRectrestore

html2canvas函数回调中调用绘制蒙层函数

html2canvas(document.body, {}).then(canvas => {
  
  const context = this.screenShortController.value?.getContext("2d");
  if (context == null) return;
  
  drawMasking(context);
})

绘制镂空选区

我们在黑色蒙层中拖拽时,需要获取鼠标按下时的起始点坐标以及鼠标移动时的坐标,根据起始点坐标和移动时的坐标,我们就可以得到一个区域,此时我们将这块区域的蒙层凿开,将获取到的 canvas 图片内容绘制到蒙层下方,这样我们就实现了镂空选区效果。

整理下上述话语,思路如下:

  • 监听鼠标按下、移动、抬起事件
  • 获取鼠标按下、移动时的坐标
  • 根据获取到的坐标凿开蒙层
  • 将获取到的 canvas 图片内容绘制到蒙层下方
  • 实现镂空选区的拖拽与缩放

实现的效果如下:

具体代码如下:

export default class EventMonitoring {
  private readonly data: InitData;
  
  private screenShortController: Ref<HTMLCanvasElement | null>;
  
  private screenShortImageController: HTMLCanvasElement | undefined;
  
  private screenShortCanvas: CanvasRenderingContext2D | undefined;
  
  private drawGraphPosition: positionInfoType = {
    startX: 0,
    startY: 0,
    width: 0,
    height: 0
  };
  
  private tempGraphPosition: positionInfoType = {
    startX: 0,
    startY: 0,
    width: 0,
    height: 0
  };
  
  private cutOutBoxBorderArr: Array<cutOutBoxBorder> = [];
  
  private borderSize = 10;
  
  private borderOption: number | null = null;
  
  private movePosition: movePositionType = {
    moveStartX: 0,
    moveStartY: 0
  };
  
  private draggingTrim = false;
  
  private dragging = false;
  
  private clickFlag = false;
  
  constructor(props: Record<string, any>, context: SetupContext<any>) {
    this.data = new InitData();
    
    this.screenShortController = this.data.getScreenShortController();
    
    onMounted(() => {
      
      this.data.setScreenShortInfo(window.innerWidth, window.innerHeight);
      
      html2canvas(document.body, {}).then(canvas => {
        
        if (this.screenShortController.value == null) return;
        
        this.screenShortImageController = canvas;
        
        const context = this.screenShortController.value?.getContext("2d");
        if (context == null) return;

        
        this.screenShortCanvas = context;
        
        drawMasking(context);

        
        this.screenShortController.value?.addEventListener(
          "mousedown",
          this.mouseDownEvent
        );
        this.screenShortController.value?.addEventListener(
          "mousemove",
          this.mouseMoveEvent
        );
        this.screenShortController.value?.addEventListener(
          "mouseup",
          this.mouseUpEvent
        );
      })
    })
  }
  
  private mouseDownEvent = (event: MouseEvent) => {
    this.dragging = true;
    this.clickFlag = true;
    
    const mouseX = nonNegativeData(event.offsetX);
    const mouseY = nonNegativeData(event.offsetY);
    
    
    if (this.borderOption) {
      
      this.draggingTrim = true;
      
      this.movePosition.moveStartX = mouseX;
      this.movePosition.moveStartY = mouseY;
    } else {
      
      this.drawGraphPosition.startX = mouseX;
      this.drawGraphPosition.startY = mouseY;
    }
  }
  
  
  private mouseMoveEvent = (event: MouseEvent) => {
    this.clickFlag = false;
    
    
    const { startX, startY, width, height } = this.drawGraphPosition;
    
    const currentX = nonNegativeData(event.offsetX);
    const currentY = nonNegativeData(event.offsetY);
    
    const tempWidth = currentX - startX;
    const tempHeight = currentY - startY;
    
    
    this.operatingCutOutBox(
      currentX,
      currentY,
      startX,
      startY,
      width,
      height,
      this.screenShortCanvas
    );
    
    if (!this.dragging || this.draggingTrim) return;
    
    this.tempGraphPosition = drawCutOutBox(
      startX,
      startY,
      tempWidth,
      tempHeight,
      this.screenShortCanvas,
      this.borderSize,
      this.screenShortController.value as HTMLCanvasElement,
      this.screenShortImageController as HTMLCanvasElement
    ) as drawCutOutBoxReturnType;
  }
  
    
  private mouseUpEvent = () => {
    
    this.dragging = false;
    this.draggingTrim = false;
    
    
    this.drawGraphPosition = this.tempGraphPosition;
    
    
    if (!this.data.getToolClickStatus().value) {
      const { startX, startY, width, height } = this.drawGraphPosition;
      this.data.setCutOutBoxPosition(startX, startY, width, height);
    }
    
    this.cutOutBoxBorderArr = saveBorderArrInfo(
      this.borderSize,
      this.drawGraphPosition
    );
  }
}

绘制镂空选区的代码较多,此处仅仅展示了鼠标的三个事件监听的相关代码,完整代码请移步:EventMonitoring.ts

绘制裁剪框的代码如下

export function drawCutOutBox(
  mouseX: number,
  mouseY: number,
  width: number,
  height: number,
  context: CanvasRenderingContext2D,
  borderSize: number,
  controller: HTMLCanvasElement,
  imageController: HTMLCanvasElement
) {
  
  const canvasWidth = controller?.width;
  const canvasHeight = controller?.height;

  
  if (!canvasWidth || !canvasHeight || !imageController || !controller) return;

  
  context.clearRect(0, 0, canvasWidth, canvasHeight);

  
  context.save();
  context.fillStyle = "rgba(0, 0, 0, .6)";
  context.fillRect(0, 0, canvasWidth, canvasHeight);
  
  context.globalCompositeOperation = "source-atop";
  
  context.clearRect(mouseX, mouseY, width, height);
  
  context.globalCompositeOperation = "source-over";
  context.fillStyle = "#2CABFF";
  
  const size = borderSize;
  
  context.fillRect(mouseX - size / 2, mouseY - size / 2, size, size);
  context.fillRect(
    mouseX - size / 2 + width / 2,
    mouseY - size / 2,
    size,
    size
  );
  context.fillRect(mouseX - size / 2 + width, mouseY - size / 2, size, size);
  context.fillRect(
    mouseX - size / 2,
    mouseY - size / 2 + height / 2,
    size,
    size
  );
  context.fillRect(
    mouseX - size / 2 + width,
    mouseY - size / 2 + height / 2,
    size,
    size
  );
  context.fillRect(mouseX - size / 2, mouseY - size / 2 + height, size, size);
  context.fillRect(
    mouseX - size / 2 + width / 2,
    mouseY - size / 2 + height,
    size,
    size
  );
  context.fillRect(
    mouseX - size / 2 + width,
    mouseY - size / 2 + height,
    size,
    size
  );
  
  context.restore();
  
  context.save();
  context.globalCompositeOperation = "destination-over";
  context.drawImage(
    imageController,
    0,
    0,
    controller?.width,
    controller?.height
  );
  context.restore();
  
  return {
    startX: mouseX,
    startY: mouseY,
    width: width,
    height: height
  };
}

同样的,注释写的很详细,上述代码用到的 canvas API 除了之前介绍的外,用到的新的 API 如下:globalCompositeOperationdrawImage

实现截图工具栏

我们实现镂空选区的相关功能后,接下来要做的就是在选区内进行圈选、框选、画线等操作了,在 QQ 的截图中这些操作位于截图工具栏内,因此我们要将截图工具栏做出来,做到与 canvas 交互。

在截图工具栏的布局上,一开始我的想法是直接在 canvas 画布中把这些工具画出来,这样应该更容易交互一点,但是我看了相关的 api 后,发现有点麻烦,把问题复杂化了。

琢磨了一阵后,想明白了,这块还是需要使用 div 进行布局的,在裁剪框绘制完毕后,根据裁剪框的位置信息计算出截图工具栏的位置,改变其位置即可。

工具栏与 canvas 的交互,可以绑定一个点击事件到EventMonitoring.ts中,获取当前点击项,指定与之对应的图形绘制函数。

实现的效果如下:

具体的实现过程如下:

screen-short.vue 中,创建截图工具栏 div 并布局好其样式

<template>
  <teleport to="body">
       
    <div
     
      v-show="toolStatus"
      :style="{ left: toolLeft + 'px', top: toolTop + 'px' }"
      ref="toolController"
    >
      <div
        v-for="item in toolbar"
        :key="item.id"
        :class="`item-panel ${item.title} `"
        @click="toolClickEvent(item.title, item.id, $event)"
      ></div>
      
      <div
        v-if="undoStatus"
        class="item-panel undo"
        @click="toolClickEvent('undo', 9, $event)"
      ></div>
      <div v-else class="item-panel undo-disabled"></div>
      
      <div
        class="item-panel close"
        @click="toolClickEvent('close', 10, $event)"
      ></div>
      <div
        class="item-panel confirm"
        @click="toolClickEvent('confirm', 11, $event)"
      ></div>
    </div>
  </teleport>
</template>

<script lang="ts">
import eventMonitoring from "@/module/main-entrance/EventMonitoring";
import toolbar from "@/module/config/Toolbar.ts";

export default {
  name: "screen-short",
  setup(props: Record<string, any>, context: SetupContext<any>) {
    const event = new eventMonitoring(props, context as SetupContext<any>);
    const toolClickEvent = event.toolClickEvent;
    return {
      toolClickEvent,
      toolbar
    }
  }
}
</script>

上述代码仅展示了组件的部分代码,完整代码请移步:screen-short.vuescreen-short.scss

截图工具条目点击样式处理

截图工具栏中的每一个条目都拥有三种状态:正常状态、鼠标移入、点击,此处我的做法是将所有状态写在 css 里了,通过不同的 class 名来显示不同的样式。

部分工具栏点击状态的 css 如下:

.square-active {
  background-image: url("~@/assets/img/square-click.png");
}

.round-active {
  background-image: url("~@/assets/img/round-click.png");
}

.right-top-active {
  background-image: url("~@/assets/img/right-top-click.png");
}

一开始我想在 v-for 渲染时,定义一个变量,点击时改变这个变量的状态,显示每个点击条目对应的点击时的样式,但是我在做的时候却发现问题了,我的点击时的 class 名是动态的,没发通过这种形式来弄,无奈我只好选择 dom 操作的形式来实现,点击时传$event到函数,获取当前点击项点击时的 class,判断其是否有选中的 class,如果有就删除,然后为当前点击项添加 class。

实现代码如下:

dom 结构

<div
    v-for="item in toolbar"
    :key="item.id"
    :class="`item-panel ${item.title} `"
    @click="toolClickEvent(item.title, item.id, $event)"
></div>

工具栏点击事件

  
  public toolClickEvent = (
    toolName: string,
    index: number,
    mouseEvent: MouseEvent
  ) => {
    
    setSelectedClassName(mouseEvent, index, false);
  }

为当前点击项添加选中时的 class,移除其兄弟元素选中时的 class

import { getSelectedClassName } from "@/module/common-methords/GetSelectedCalssName";
import { getBrushSelectedName } from "@/module/common-methords/GetBrushSelectedName";


export function setSelectedClassName(
  mouseEvent: any,
  index: number,
  isOption: boolean
) {
  
  let className = getSelectedClassName(index);
  if (isOption) {
    
    className = getBrushSelectedName(index);
  }
  
  const nodes = mouseEvent.path[1].children;
  for (let i = 0; i < nodes.length; i++) {
    const item = nodes[i];
    
    if (item.className.includes("active")) {
      item.classList.remove(item.classList[2]);
    }
  }
  
  mouseEvent.target.className += " " + className;
}

获取截图工具栏点击时的 class 名

export function getSelectedClassName(index: number) {
  let className = "";
  switch (index) {
    case 1:
      className = "square-active";
      break;
    case 2:
      className = "round-active";
      break;
    case 3:
      className = "right-top-active";
      break;
    case 4:
      className = "brush-active";
      break;
    case 5:
      className = "mosaicPen-active";
      break;
    case 6:
      className = "text-active";
  }
  return className;
}

获取画笔选择点击时的 class 名

export function getBrushSelectedName(itemName: number) {
  let className = "";
  switch (itemName) {
    case 1:
      className = "brush-small-active";
      break;
    case 2:
      className = "brush-medium-active";
      break;
    case 3:
      className = "brush-big-active";
      break;
  }
  return className;
}

实现工具栏中的每个选项

接下来,我们来看看工具栏中每个选项的具体实现。

工具栏中每个图形的绘制都需要鼠标按下、移动、抬起这三个事件的配合下完成,为了防止鼠标在移动时图形重复绘制,这里我们采用 "历史记录" 模式来解决这个问题,我们先来看下重复绘制时的场景,如下所示:

接下来,我们来看下如何使用历史记录来解决这个问题。

首先,我们需要定义一个数组变量,取名为 history

private history: Array<Record<string, any>> = [];

当图形绘制结束鼠标抬起时,将当前画布状态保存至 history

  
  private addHistoy() {
    if (
      this.screenShortCanvas != null &&
      this.screenShortController.value != null
    ) {
      
      const context = this.screenShortCanvas;
      const controller = this.screenShortController.value;
      if (this.history.length > this.maxUndoNum) {
        
        this.history.unshift();
      }
      
      this.history.push({
        data: context.getImageData(0, 0, controller.width, controller.height)
      });
      
      this.data.setUndoStatus(true);
    }
  }
  • 当鼠标处于移动状态时,我们取出history中最后一条记录。
  
  private showLastHistory() {
    if (this.screenShortCanvas != null) {
      const context = this.screenShortCanvas;
      if (this.history.length <= 0) {
        this.addHistoy();
      }
      context.putImageData(this.history[this.history.length - 1]["data"], 0, 0);
    }
  }

上述函数放在合适的时机执行,即可解决图形重复绘制的问题,接下来我们看下解决后的绘制效果,如下所示:

实现矩形绘制

在前面的分析中,我们拿到了鼠标的起始点坐标和鼠标移动时的坐标,我们可以通过这些数据计算出框选区域的宽高,如下所示。

const { startX, startY } = this.drawGraphPosition;

const currentX = nonNegativeData(event.offsetX);
const currentY = nonNegativeData(event.offsetY);

const tempWidth = currentX - startX;
const tempHeight = currentY - startY;

我们拿到这些数据后,即可通过 canvas 的 rect 这个 API 来绘制一个矩形了,代码如下所示:

export function drawRectangle(
  mouseX: number,
  mouseY: number,
  width: number,
  height: number,
  color: string,
  borderWidth: number,
  context: CanvasRenderingContext2D,
  controller: HTMLCanvasElement,
  imageController: HTMLCanvasElement
) {
  context.save();
  
  context.strokeStyle = color;
  
  context.lineWidth = borderWidth;
  context.beginPath();
  
  context.rect(mouseX, mouseY, width, height);
  context.stroke();
  
  context.restore();
  
  context.save();
  context.globalCompositeOperation = "destination-over";
  context.drawImage(
    imageController,
    0,
    0,
    controller?.width,
    controller?.height
  );
  
  context.restore();
}

实现椭圆绘制

在绘制椭圆时,我们需要根据坐标信息计算出圆的半径、圆心坐标,随后调用 ellipse 函数即可绘制一个椭圆出来,代码如下所示:

export function drawCircle(
  context: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number,
  mouseStartX: number,
  mouseStartY: number,
  borderWidth: number,
  color: string
) {
  
  const startX = mouseX < mouseStartX ? mouseX : mouseStartX;
  const startY = mouseY < mouseStartY ? mouseY : mouseStartY;
  const endX = mouseX >= mouseStartX ? mouseX : mouseStartX;
  const endY = mouseY >= mouseStartY ? mouseY : mouseStartY;
  
  const radiusX = (endX - startX) * 0.5;
  const radiusY = (endY - startY) * 0.5;
  
  const centerX = startX + radiusX;
  const centerY = startY + radiusY;
  
  context.save();
  context.beginPath();
  context.lineWidth = borderWidth;
  context.strokeStyle = color;

  if (typeof context.ellipse === "function") {
    
    context.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI);
  } else {
    throw "你的浏览器不支持ellipse,无法绘制椭圆";
  }
  context.stroke();
  context.closePath();
  
  context.restore();
}

注释已经写的很清楚了,此处用到的 API 有:beginPathlineWidthellipseclosePath,对这些 API 不熟悉的开发者请移步到指定位置进行查阅。

实现箭头绘制

箭头绘制相比其他工具来说是最复杂的,因为我们需要通过三角函数来计算箭头两个点的坐标,通过三角函数中的反正切函数来计算箭头的角度

既然需要用到三角函数来实现,那我们先来看下我们的已知条件:

  

如上图所示,P1 为鼠标按下时的坐标,P2 为鼠标移动时的坐标,夹角θ的角度为 30,我们知道这些信息后就可以求出 P3 和 P4 的坐标了,求出坐标后我们即可通过 canvas 的 moveTo、lineTo 来绘制箭头了。

实现代码如下:

export function drawLineArrow(
  context: CanvasRenderingContext2D,
  mouseStartX: number,
  mouseStartY: number,
  mouseX: number,
  mouseY: number,
  theta: number,
  headlen: number,
  borderWidth: number,
  color: string
) {
  
  const angle =
      (Math.atan2(mouseStartY - mouseY, mouseStartX - mouseX) * 180) / Math.PI, 
    angle1 = ((angle + theta) * Math.PI) / 180, 
    angle2 = ((angle - theta) * Math.PI) / 180, 
    topX = headlen * Math.cos(angle1), 
    topY = headlen * Math.sin(angle1), 
    botX = headlen * Math.cos(angle2), 
    botY = headlen * Math.sin(angle2); 

  
  context.save();
  context.beginPath();

  
  let arrowX = mouseStartX - topX,
    arrowY = mouseStartY - topY;

  
  context.moveTo(arrowX, arrowY);
  
  context.moveTo(mouseStartX, mouseStartY);
  
  context.lineTo(mouseX, mouseY);
  
  arrowX = mouseX + topX;
  arrowY = mouseY + topY;
  
  context.moveTo(arrowX, arrowY);
  
  context.lineTo(mouseX, mouseY);
  
  arrowX = mouseX + botX;
  arrowY = mouseY + botY;
  
  context.lineTo(arrowX, arrowY);
  
  context.strokeStyle = color;
  context.lineWidth = borderWidth;
  
  context.stroke();
  
  context.restore();
}

此处用到的新 API 有:moveTolineTo,对这些 API 不熟悉的开发者请移步到指定位置进行查阅。

实现画笔绘制

画笔的绘制我们需要通过 lineTo 来实现,不过在绘制时需要注意:在鼠标按下时需要通过 beginPath 来清空一条路径,并移动画笔笔触到鼠标按下时的位置,否则鼠标的起始位置始终是 0,bug 如下所示:

那么要解决这个 bug,就需要在鼠标按下时初始化一下笔触位置,代码如下:

export function initPencli(
  context: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number
) {
  
  context.beginPath();
  
  context.moveTo(mouseX, mouseY);
}

随后,再鼠标位置时根据坐标信息绘制线条即可,代码如下:

export function drawPencli(
  context: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number,
  size: number,
  color: string
) {
  
  context.save();
  
  context.lineWidth = size;
  
  context.strokeStyle = color;
  context.lineTo(mouseX, mouseY);
  context.stroke();
  
  context.restore();
}

实现马赛克绘制

我们都知道图片是由一个个像素点构成的,当我们把某个区域的像素点设置成同样的颜色,这块区域的信息就会被破坏掉,被我们破坏掉的区域就叫马赛克。

知道马赛克的原理后,我们就可以分析出实现思路:

  • 获取鼠标划过路径区域的图像信息
  • 将区域内的像素点绘制成周围相近的颜色

具体的实现代码如下:

const getAxisColor = (imgData: ImageData, x: number, y: number) => {
  const w = imgData.width;
  const d = imgData.data;
  const color = [];
  color[0] = d[4 * (y * w + x)];
  color[1] = d[4 * (y * w + x) + 1];
  color[2] = d[4 * (y * w + x) + 2];
  color[3] = d[4 * (y * w + x) + 3];
  return color;
};


const setAxisColor = (
  imgData: ImageData,
  x: number,
  y: number,
  color: Array<number>
) => {
  const w = imgData.width;
  const d = imgData.data;
  d[4 * (y * w + x)] = color[0];
  d[4 * (y * w + x) + 1] = color[1];
  d[4 * (y * w + x) + 2] = color[2];
  d[4 * (y * w + x) + 3] = color[3];
};


export function drawMosaic(
  mouseX: number,
  mouseY: number,
  size: number,
  degreeOfBlur: number,
  context: CanvasRenderingContext2D
) {
  
  const imgData = context.getImageData(mouseX, mouseY, size, size);
  
  const w = imgData.width;
  const h = imgData.height;
  
  const stepW = w / degreeOfBlur;
  const stepH = h / degreeOfBlur;
  
  for (let i = 0; i < stepH; i++) {
    for (let j = 0; j < stepW; j++) {
      
      const color = getAxisColor(
        imgData,
        j * degreeOfBlur + Math.floor(Math.random() * degreeOfBlur),
        i * degreeOfBlur + Math.floor(Math.random() * degreeOfBlur)
      );
      
      for (let k = 0; k < degreeOfBlur; k++) {
        for (let l = 0; l < degreeOfBlur; l++) {
          
          setAxisColor(
            imgData,
            j * degreeOfBlur + l,
            i * degreeOfBlur + k,
            color
          );
        }
      }
    }
  }
  
  context.putImageData(imgData, mouseX, mouseY);
}

实现文字绘制

canvas 没有直接提供 API 来供我们输入文字,但是它提供了填充文本的 API,因此我们需要一个 div 来让用户输入文字,用户输入完成后将输入的文字填充到指定区域即可。

实现的效果如下:

在组件中创建一个 div,开启 div 的可编辑属性,布局好样式

<template>
  <teleport to="body">
		
    <div
     
      ref="textInputController"
      v-show="textStatus"
      contenteditable="true"
      spellcheck="false"
    ></div>
  </teleport>
</template>

鼠标按下时,计算文本输入区域位置

const textMouseX = mouseX - 15;
const textMouseY = mouseY - 15;

this.textInputController.value.style.left = textMouseX + "px";
this.textInputController.value.style.top = textMouseY + "px";

输入框位置发生变化时代表用户输入完毕,将用户输入的内容渲染到 canvas,绘制文本的代码如下

export function drawText(
  text: string,
  mouseX: number,
  mouseY: number,
  color: string,
  fontSize: number,
  context: CanvasRenderingContext2D
) {
  
  context.save();
  context.lineWidth = 1;
  
  context.fillStyle = color;
  context.textBaseline = "middle";
  context.font = `bold ${fontSize}px 微软雅黑`;
  context.fillText(text, mouseX, mouseY);
  
  context.restore();
}

实现下载功能

下载功能比较简单,我们只需要将裁剪框区域的内容放进一个新的 canvas 中,然后调用 toDataURL 方法就能拿到图片的 base64 地址,我们创建一个 a 标签,添加 download 属性,出发 a 标签的点击事件即可下载。

实现代码如下:

export function saveCanvasToImage(
  context: CanvasRenderingContext2D,
  startX: number,
  startY: number,
  width: number,
  height: number
) {
  
  const img = context.getImageData(startX, startY, width, height);
  
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  
  const imgContext = canvas.getContext("2d");
  if (imgContext) {
    
    imgContext.putImageData(img, 0, 0);
    const a = document.createElement("a");
    
    a.href = canvas.toDataURL("png");
    
    a.download = `${new Date().getTime()}.png`;
    a.click();
  }
}

实现撤销功能

由于我们绘制图形采用了历史记录模式,每次图形绘制都会存储一次画布状态,我们只需要在点击撤销按钮时,从 history 弹出一最后一条记录即可。

实现代码如下:

private takeOutHistory() {
  const lastImageData = this.history.pop();
  if (this.screenShortCanvas != null && lastImageData) {
    const context = this.screenShortCanvas;
    if (this.undoClickNum == 0 && this.history.length > 0) {
      
      const firstPopImageData = this.history.pop() as Record<string, any>;
      context.putImageData(firstPopImageData["data"], 0, 0);
    } else {
      context.putImageData(lastImageData["data"], 0, 0);
    }
  }

  this.undoClickNum++;
  
  if (this.history.length <= 0) {
    this.undoClickNum = 0;
    this.data.setUndoStatus(false);
  }
}

实现关闭功能

关闭功能指的是重置截图组件,因此我们需要通过 emit 向父组件推送销毁的消息。

实现代码如下:

  
  private resetComponent = () => {
    if (this.emit) {
      
      this.data.setToolStatus(false);
      
      this.data.setInitStatus(true);
      
      this.emit("destroy-component", false);
      return;
    }
    throw "组件重置失败";
  };

实现确认功能

当用户点击确认后,我们需要将裁剪框内的内容转为 base64,然后通过 emit 推送给付组件,最后重置组件。

实现代码如下:

const base64 = this.getCanvasImgData(false);
this.emit("get-image-data", base64);

插件地址

至此,插件的实现过程就分享完毕了。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

落花随流水

暂无简介

文章
评论
28 人气
更多

推荐作者

櫻之舞

文章 0 评论 0

弥枳

文章 0 评论 0

m2429

文章 0 评论 0

野却迷人

文章 0 评论 0

我怀念的。

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文