實現Web端自定義截屏


前言

當客戶在使用我們的產品過程中,遇到問題需要向我們反饋時,如果用純文字的形式描述,我們很難懂客戶的意思,要是能配上問題截圖,這樣我們就能很清楚的知道客戶的問題了。

那么,我們就需要為我們的產品實現一個自定義截屏的功能,用戶點完"截圖"按鈕后,框選任意區域,隨后在框選的區域內進行圈選、畫箭頭、馬賽克、直線、打字等操作,做完操作后用戶可以選擇保存框選區域的內容到本地或者直接發送給我們。

聰明的開發者可能已經猜到了,這是QQ/微信的截圖功能,我的開源項目正好做到了截圖功能,在做之前我找了很多資料,沒有發現web端有這種東西存在,於是我就決定參照QQ的截圖自己實現一個並做成插件供大家使用。

本文就跟大家分享下我在做這個"自定義截屏功能"時的實現思路以及過程,歡迎各位感興趣的開發者閱讀本文。

 

寫在前面

本文插件的寫法采用的是Vue3的compositionAPI,如果對其不了解的開發者請移步我的另一篇文章:使用Vue3的CompositionAPI來優化代碼量

實現思路

我們先來看下QQ的截屏流程,進而分析它是怎么實現的。

截屏流程分析

我們先來分析下,截屏時的具體流程。

  • 點擊截屏按鈕后,我們會發現頁面上所有動態效果都靜止不動了,如下所示。

     

     



  • 隨后,我們按住鼠標左鍵進行拖動,屏幕上會出現黑色蒙板,鼠標的拖動區域會出現鏤空效果,如下所示(此處圖片過大,無法展示請移步原文查看)

  • 完成拖拽后,框選區域的下方會出現工具欄,里面有框選、圈選、箭頭、直線、畫筆等工具,如下圖所示。

     

     

  • 點擊工具欄中任意一個圖標,會出現畫筆選擇區域,在這里可以選擇畫筆大小、顏色如下所示。

     

     

  • 隨后,我們在框選的區域內進行拖拽就會繪制出對應的圖形,如下所示。




  • 最后,點擊截圖工具欄的下載圖標即可將圖片保存至本地,或者點擊對號圖片會自動粘貼到聊天輸入框,如下所示。

     

     

截屏實現思路

通過上述截屏流程,我們便得到了下述實現思路:

  • 獲取當前可視區域的內容,將其存儲起來
  • 為整個cnavas畫布繪制蒙層
  • 在獲取到的內容中進行拖拽,繪制鏤空選區
  • 選擇截圖工具欄的工具,選擇畫筆大小等信息
  • 在選區內拖拽繪制對應的圖形
  • 將選區內的內容轉換為圖片

實現過程

我們分析出了實現思路,接下來我們將上述思路逐一進行實現。

獲取當前可視區域內容

當點擊截圖按鈕后,我們需要獲取整個可視區域的內容,后續所有的操作都是在獲取的內容上進行的,在web端我們可以使用canvas來實現這些操作。

那么,我們就需要先將body區域的內容轉換為canvas,如果要從零開始實現這個轉換,有點復雜而且工作量很大。

還好在前端社區中有個開源庫叫html2canvas可以實現將指定dom轉換為canvas,我們就采用這個庫來實現我們的轉換。

接下來,我們來看下具體實現過程:

新建一個名為screen-short.vue的文件,用於承載我們的整個截圖組件。

  • 首先我們需要一個canvas容器來顯示轉換后的可視區域內容
<template>
  <teleport to="body">
    <!--截圖區域-->
    <canvas
      id="screenShotContainer"
      :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 {
  // 當前實例的響應式data數據
  private readonly data: InitData;
  // 截圖區域canvas容器
  private screenShortController: Ref<HTMLCanvasElement | null>;
  // 截圖圖片存放容器
  private screenShortImageController: HTMLCanvasElement | undefined;
  
  constructor(props: Record<string, any>, context: SetupContext<any>) {
    // 實例化響應式data
    this.data = new InitData();
    // 獲取截圖區域canvas容器
    this.screenShortController = this.data.getScreenShortController();
    
    onMounted(() => {
      // 設置截圖區域canvas寬高
      this.data.setScreenShortInfo(window.innerWidth, window.innerHeight);
      
      html2canvas(document.body, {}).then(canvas => {
        // 裝載截圖的dom為null則退出
        if (this.screenShortController.value == null) return;
        
        // 存放html2canvas截取的內容
        this.screenShortImageController = canvas;
      })
    })
  }
}

 

此處只展示了部分代碼,完整代碼請移步:EventMonitoring.ts

為canvas畫布繪制蒙層

我們拿到了轉換后的dom后,我們就需要繪制一個透明度為0.6的黑色蒙層,告知用戶你現在處於截屏區域選區狀態。

具體實現過程如下:

  • 創建DrawMasking.ts文件,蒙層的繪制邏輯在此文件中實現,代碼如下。
/**
 * 繪制蒙層
 * @param context 需要進行繪制canvas
 */
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不懂的開發者請移步:clearRect、save、fillStyle、fillRect、restore

  • html2canvas函數回調中調用繪制蒙層函數
html2canvas(document.body, {}).then(canvas => {
  // 獲取截圖區域畫canvas容器畫布
  const context = this.screenShortController.value?.getContext("2d");
  if (context == null) return;
  // 繪制蒙層
  drawMasking(context);
})
 

繪制鏤空選區

我們在黑色蒙層中拖拽時,需要獲取鼠標按下時的起始點坐標以及鼠標移動時的坐標,根據起始點坐標和移動時的坐標,我們就可以得到一個區域,此時我們將這塊區域的蒙層鑿開,將獲取到的canvas圖片內容繪制到蒙層下方,這樣我們就實現了鏤空選區效果。

整理下上述話語,思路如下:

  • 監聽鼠標按下、移動、抬起事件
  • 獲取鼠標按下、移動時的坐標
  • 根據獲取到的坐標鑿開蒙層
  • 將獲取到的canvas圖片內容繪制到蒙層下方
  • 實現鏤空選區的拖拽與縮放

實現的效果如下:

 

 

 

具體代碼如下:

export default class EventMonitoring {
   // 當前實例的響應式data數據
  private readonly data: InitData;
  
  // 截圖區域canvas容器
  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>) {
     // 實例化響應式data
    this.data = new InitData();
    
    // 獲取截圖區域canvas容器
    this.screenShortController = this.data.getScreenShortController();
    
    onMounted(() => {
      // 設置截圖區域canvas寬高
      this.data.setScreenShortInfo(window.innerWidth, window.innerHeight);
      
      html2canvas(document.body, {}).then(canvas => {
        // 裝載截圖的dom為null則退出
        if (this.screenShortController.value == null) return;

        // 存放html2canvas截取的內容
        this.screenShortImageController = canvas;
        // 獲取截圖區域畫canvas容器畫布
        const context = this.screenShortController.value?.getContext("2d");
        if (context == null) return;

        // 賦值截圖區域canvas畫布
        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
    );
    // 如果鼠標未點擊或者當前操作的是裁剪框都return
    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

  • 繪制裁剪框的代碼如下
/**
 * 繪制裁剪框
 * @param mouseX 鼠標x軸坐標
 * @param mouseY 鼠標y軸坐標
 * @param width 裁剪框寬度
 * @param height 裁剪框高度
 * @param context 需要進行繪制的canvas畫布
 * @param borderSize 邊框節點直徑
 * @param controller 需要進行操作的canvas容器
 * @param imageController 圖片canvas容器
 * @private
 */
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;

  // 畫布、圖片不存在則return
  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);
  // 繪制8個邊框像素點並保存坐標信息以及事件參數
  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();
  // 使用drawImage將圖片繪制到蒙層下方
  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如下:globalCompositeOperation、drawImage

實現截圖工具欄

我們實現鏤空選區的相關功能后,接下來要做的就是在選區內進行圈選、框選、畫線等操作了,在QQ的截圖中這些操作位於截圖工具欄內,因此我們要將截圖工具欄做出來,做到與canvas交互。

在截圖工具欄的布局上,一開始我的想法是直接在canvas畫布中把這些工具畫出來,這樣應該更容易交互一點,但是我看了相關的api后,發現有點麻煩,把問題復雜化了。

琢磨了一陣后,想明白了,這塊還是需要使用div進行布局的,在裁剪框繪制完畢后,根據裁剪框的位置信息計算出截圖工具欄的位置,改變其位置即可。

工具欄與canvas的交互,可以綁定一個點擊事件到EventMonitoring.ts中,獲取當前點擊項,指定與之對應的圖形繪制函數。

實現的效果如下:

具體的實現過程如下:

  • screen-short.vue中,創建截圖工具欄div並布局好其樣式
<template>
  <teleport to="body">
       <!--工具欄-->
    <div
      id="toolPanel"
      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.vue、screen-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>
 
  • 工具欄點擊事件
 
 /**
   * 裁剪框工具欄點擊事件
   * @param toolName
   * @param index
   * @param mouseEvent
   */
  public toolClickEvent = (
    toolName: string,
    index: number,
    mouseEvent: MouseEvent
  ) => {
    // 為當前點擊項添加選中時的class名
    setSelectedClassName(mouseEvent, index, false);
  }
 
  • 為當前點擊項添加選中時的class,移除其兄弟元素選中時的class
import { getSelectedClassName } from "@/module/common-methords/GetSelectedCalssName";
import { getBrushSelectedName } from "@/module/common-methords/GetBrushSelectedName";

/**
 * 為當前點擊項添加選中時的class,移除其兄弟元素選中時的class
 * @param mouseEvent 需要進行操作的元素
 * @param index 當前點擊項
 * @param isOption 是否為畫筆選項
 */
export function setSelectedClassName(
  mouseEvent: any,
  index: number,
  isOption: boolean
) {
  // 獲取當前點擊項選中時的class名
  let className = getSelectedClassName(index);
  if (isOption) {
    // 獲取畫筆選項選中時的對應的class
    className = getBrushSelectedName(index);
  }
  // 獲取div下的所有子元素
  const nodes = mouseEvent.path[1].children;
  for (let i = 0; i < nodes.length; i++) {
    const item = nodes[i];
    // 如果工具欄中已經有選中的class則將其移除
    if (item.className.includes("active")) {
      item.classList.remove(item.classList[2]);
    }
  }
  // 給當前點擊項添加選中時的class
  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名
/**
 * 獲取畫筆選項對應的選中時的class名
 * @param itemName
 */
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
   */
  private addHistoy() {
    if (
      this.screenShortCanvas != null &&
      this.screenShortController.value != null
    ) {
      // 獲取canvas畫布與容器
      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
   */
  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來繪制一個矩形了,代碼如下所示:

/**
 * 繪制矩形
 * @param mouseX
 * @param mouseY
 * @param width
 * @param height
 * @param color 邊框顏色
 * @param borderWidth 邊框大小
 * @param context 需要進行繪制的canvas畫布
 * @param controller 需要進行操作的canvas容器
 * @param imageController 圖片canvas容器
 */
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();
  // 使用drawImage將圖片繪制到蒙層下方
  context.save();
  context.globalCompositeOperation = "destination-over";
  context.drawImage(
    imageController,
    0,
    0,
    controller?.width,
    controller?.height
  );
  // 繪制結束
  context.restore();
}

 

 

實現橢圓繪制

在繪制橢圓時,我們需要根據坐標信息計算出圓的半徑、圓心坐標,隨后調用ellipse函數即可繪制一個橢圓出來,代碼如下所示:

/**
 * 繪制圓形
 * @param context 需要進行繪制的畫布
 * @param mouseX 當前鼠標x軸坐標
 * @param mouseY 當前鼠標y軸坐標
 * @param mouseStartX 鼠標按下時的x軸坐標
 * @param mouseStartY 鼠標按下時的y軸坐標
 * @param borderWidth 邊框寬度
 * @param color 邊框顏色
 */
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;
  // 計算圓心的x、y坐標
  const centerX = startX + radiusX;
  const centerY = startY + radiusY;
  // 開始繪制
  context.save();
  context.beginPath();
  context.lineWidth = borderWidth;
  context.strokeStyle = color;

  if (typeof context.ellipse === "function") {
    // 繪制圓,旋轉角度與起始角度都為0,結束角度為2*PI
    context.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI);
  } else {
    throw "你的瀏覽器不支持ellipse,無法繪制橢圓";
  }
  context.stroke();
  context.closePath();
  // 結束繪制
  context.restore();
}

 

 

⚠️注釋已經寫的很清楚了,此處用到的API有:beginPath、lineWidth、ellipse、closePath,對這些API不熟悉的開發者請移步到指定位置進行查閱。

實現箭頭繪制

箭頭繪制相比其他工具來說是最復雜的,因為我們需要通過三角函數來計算箭頭兩個點的坐標,通過三角函數中的反正切函數來計算箭頭的角度

既然需要用到三角函數來實現,那我們先來看下我們的已知條件:

  
/**
   * 已知:
   *    1. P1、P2的坐標
   *    2. 箭頭斜線P3到P2直線的長度,P4與P3是對稱的,因此P4到P2的長度等於P3到P2的長度
   *    3. 箭頭斜線P3到P1、P2直線的夾角角度(θ),因為是對稱的,所以P4與P1、P2直線的夾角角度是相等的
   * 求:
   *    P3、P4的坐標
   */
 

 

 

 

如上圖所示,P1為鼠標按下時的坐標,P2為鼠標移動時的坐標,夾角θ的角度為30,我們知道這些信息后就可以求出P3和P4的坐標了,求出坐標后我們即可通過canvas的moveTo、lineTo來繪制箭頭了。

實現代碼如下:

/**
 * 繪制箭頭
 * @param context 需要進行繪制的畫布
 * @param mouseStartX 鼠標按下時的x軸坐標 P1
 * @param mouseStartY 鼠標按下時的y軸坐標 P1
 * @param mouseX 當前鼠標x軸坐標 P2
 * @param mouseY 當前鼠標y軸坐標 P2
 * @param theta 箭頭斜線與直線的夾角角度 (θ) P3 ---> (P1、P2) || P4 ---> P1(P1、P2)
 * @param headlen 箭頭斜線的長度 P3 ---> P2 || P4 ---> P2
 * @param borderWidth 邊框寬度
 * @param color 邊框顏色
 */
export function drawLineArrow(
  context: CanvasRenderingContext2D,
  mouseStartX: number,
  mouseStartY: number,
  mouseX: number,
  mouseY: number,
  theta: number,
  headlen: number,
  borderWidth: number,
  color: string
) {
  /**
   * 已知:
   *    1. P1、P2的坐標
   *    2. 箭頭斜線(P3 || P4) ---> P2直線的長度
   *    3. 箭頭斜線(P3 || P4) ---> (P1、P2)直線的夾角角度(θ)
   * 求:
   *    P3、P4的坐標
   */
  const angle =
      (Math.atan2(mouseStartY - mouseY, mouseStartX - mouseX) * 180) / Math.PI, // 通過atan2來獲取箭頭的角度
    angle1 = ((angle + theta) * Math.PI) / 180, // P3點的角度
    angle2 = ((angle - theta) * Math.PI) / 180, // P4點的角度
    topX = headlen * Math.cos(angle1), // P3點的x軸坐標
    topY = headlen * Math.sin(angle1), // P3點的y軸坐標
    botX = headlen * Math.cos(angle2), // P4點的X軸坐標
    botY = headlen * Math.sin(angle2); // P4點的Y軸坐標

  // 開始繪制
  context.save();
  context.beginPath();

  // P3的坐標位置
  let arrowX = mouseStartX - topX,
    arrowY = mouseStartY - topY;

  // 移動筆觸到P3坐標
  context.moveTo(arrowX, arrowY);
  // 移動筆觸到P1
  context.moveTo(mouseStartX, mouseStartY);
  // 繪制P1到P2的直線
  context.lineTo(mouseX, mouseY);
  // 計算P3的位置
  arrowX = mouseX + topX;
  arrowY = mouseY + topY;
  // 移動筆觸到P3坐標
  context.moveTo(arrowX, arrowY);
  // 繪制P2到P3的斜線
  context.lineTo(mouseX, mouseY);
  // 計算P4的位置
  arrowX = mouseX + botX;
  arrowY = mouseY + botY;
  // 繪制P2到P4的斜線
  context.lineTo(arrowX, arrowY);
  // 上色
  context.strokeStyle = color;
  context.lineWidth = borderWidth;
  // 填充
  context.stroke();
  // 結束繪制
  context.restore();
}

 



⚠️此處用到的新API有:moveTo、lineTo,對這些API不熟悉的開發者請移步到指定位置進行查閱。

實現畫筆繪制

畫筆的繪制我們需要通過lineTo來實現,不過在繪制時需要注意:在鼠標按下時需要通過beginPath來清空一條路徑,並移動畫筆筆觸到鼠標按下時的位置,否則鼠標的起始位置始終是0,bug如下所示:

那么要解決這個bug,就需要在鼠標按下時初始化一下筆觸位置,代碼如下:

/**
 * 畫筆初始化
 */
export function initPencli(
  context: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number
) {
  // 開始||清空一條路徑
  context.beginPath();
  // 移動畫筆位置
  context.moveTo(mouseX, mouseY);
}


隨后,在鼠標位置時根據坐標信息繪制線條即可,代碼如下:

/**
 * 畫筆繪制
 * @param context
 * @param mouseX
 * @param mouseY
 * @param size
 * @param color
 */
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();
}

 

 

實現馬賽克繪制

我們都知道圖片是由一個個像素點構成的,當我們把某個區域的像素點設置成同樣的顏色,這塊區域的信息就會被破壞掉,被我們破壞掉的區域就叫馬賽克。

知道馬賽克的原理后,我們就可以分析出實現思路:

  • 獲取鼠標划過路徑區域的圖像信息
  • 將區域內的像素點繪制成周圍相近的顏色

具體的實現代碼如下:

/**
 * 獲取圖像指定坐標位置的顏色
 * @param imgData 需要進行操作的圖片
 * @param x x點坐標
 * @param y y點坐標
 */
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;
};

/**
 * 設置圖像指定坐標位置的顏色
 * @param imgData 需要進行操作的圖片
 * @param x x點坐標
 * @param y y點坐標
 * @param 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];
};

/**
 * 繪制馬賽克
 *    實現思路:
 *      1. 獲取鼠標划過路徑區域的圖像信息
 *      2. 將區域內的像素點繪制成周圍相近的顏色
 * @param mouseX 當前鼠標X軸坐標
 * @param mouseY 當前鼠標Y軸坐標
 * @param size 馬賽克畫筆大小
 * @param degreeOfBlur 馬賽克模糊度
 * @param context 需要進行繪制的畫布
 */
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
      id="textInputPanel"
      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,繪制文本的代碼如下
/**
 * 繪制文本
 * @param text 需要進行繪制的文字
 * @param mouseX 繪制位置的X軸坐標
 * @param mouseY 繪制位置的Y軸坐標
 * @param color 字體顏色
 * @param fontSize 字體大小
 * @param context 需要進行繪制的畫布
 */
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);
  // 創建canvas標簽,用於存放裁剪區域的圖片
  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);
 
        

插件地址

至此,插件的實現過程就分享完畢了。


轉自https://juejin.cn/post/6924368956950052877#heading-22

喜歡這篇文章?歡迎打賞~~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM