基於React Hook實現圖片的裁剪

羽徵發表於2020-04-07

專案背景

最近做了個電子報專案,使用者可在上傳的報刊版面圖上劃出一個個區域,通過OCR圖文識別技術,識別出區域文字資訊,然後編輯成一條條新聞,可在PC端和手機端點選版面圖,檢視新聞詳情。

⚠️關鍵技術點: 用Canvas如何繪製出裁剪框。

本文主要介紹裁剪框的實現過程。

單個裁剪

基於React Hook實現圖片的裁剪

批量裁剪

基於React Hook實現圖片的裁剪

Canvas技術點

  • CanvasRenderingContext2D.drawImage()方法
  • CanvasRenderingContext2D.save()CanvasRenderingContext2D.restore()方法的成對使用
  • CanvasRenderingContext2D.globalCompositeOperation屬性
  • CanvasRenderingContext2D.getImageData()CanvasRenderingContext2D.putImageData方法

?小貼士:如果您對本文有興趣,期望您先了解以上技術點。

流程簡介

  1. 讀取圖片

  2. Canvas繪製圖片

    1. drawImage()的使用
    2. 繪製版面圖
  3. 裁剪操作

    1. 基本裁剪流程
    2. 裁剪框的繪製
  4. 輸出裁剪圖片

    1. getImageData()的使用
    2. putImageData()的使用
    3. 使用Canvas.toDataURL()輸出圖片
    4. 使用OCR識別圖片資訊

基於React Hook實現圖片的裁剪

一、讀取圖片

元件初始化時,通過new Image物件讀取圖片連結; 若圖片是通過本地上傳的,可用new FileReader物件讀取。

⚠️注意點

  1. 圖片的跨域問題;
  2. image.src = url放在圖片讀取後面,因為會偶發圖片讀取異常。

?實現程式碼如下:

import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Button } from 'antd';
import styles from './index.less';

/**
*file 版面檔案
*useOcr true:通過OCR轉換成文字;false:轉換為圖片
*onTransform 轉換成文字後呼叫元件外部方法
*/
export default function ({ file, useOcr, onTransform }) {
    const { url } = file;
    const [originImg, setOriginImg] = useState(); // 源圖片
    const [contentNode, setContentNode] = useState(); // 最外層節點
    const [canvasNode, setCanvasNode] = useState(); // canvas節點
    const [btnGroupNode, setBtnGroupNode] = useState(); // 按鈕組
    const [startCoordinate, setStartCoordinate] = useState([0, 0]); // 開始座標
    const [dragging, setDragging] = useState(false); // 是否可以裁剪
    const [curPoisition, setCurPoisition] = useState(null); // 當前裁剪框座標資訊
    const [trimPositionMap, setTrimPositionMap] = useState([]); // 裁剪框座標資訊
    const fileSyncUpdating = useSelector(state => state.loading.effects['digital/postImgFileWithAliOcr']);
    const dispatch = useDispatch();
    
    const initCanvas = () => {
        // url為上傳的圖片連結
        if (url == null) {
          return;
        }

        // 例項化一個Image物件,獲取圖片寬高,用於設定canvas寬高
        const image = new Image();
        image.addEventListener('load', () => {
            ...
        });
        image.crossOrigin = 'anonymous'; // 解決圖片跨域問題
        image.src = url;
    };

    useEffect(() => {
        initCanvas();
    }, [url]);
}    
複製程式碼

二、canvas繪製圖片

2.1 drawImage()的使用

基於React Hook實現圖片的裁剪

語法
ctx.drawImage(image, dx, dy)   
ctx.drawImage(image, dx, dy, dw, dh)   
ctxdrawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
複製程式碼
引數
  • image: 影像源;
  • dxdy是canvas中即將繪製區域的開始座標值;
  • dwdh是canvas中即將繪製區域的寬高;
  • 若需繪製源影像某部分,sxsy是該區域的左上角座標值;
  • 若需繪製源影像某部分,swsh是該區域的寬高。

2.2 繪製版面圖

在讀取版面圖的時候,通過呼叫CanvasRenderingContext2D.drawImage()繪製圖片。

⚠️注意: 每次呼叫canvas方法時,需要用ctx.clearRect()擦除一次,這樣可以節省記憶體,否則canvas繪製的影像會一層層疊加,雖然看上去只有一張圖。

?實現程式碼如下:

// 初始化
const initCanvas = () => {
    // url為上傳的圖片連結
    if (url == null) {
      return;
    }
    // contentNode為最外層DOM節點
    if (contentNode == null) {
      return;
    }
    // canvasNode為canvas節點
    if (canvasNode == null) {
      return;
    }

    const image = new Image();
    setOriginImg(image); // 儲存源圖
    image.addEventListener('load', () => {      
        const ctx = canvasNode.getContext('2d');
        // 擦除一次,否則canvas會一層層疊加,節省記憶體
        ctx.clearRect(0, 0, canvasNode.width, canvasNode.height);
        // 若源圖寬度大於最外層節點的clientWidth,則設定canvas寬為clientWidth,否則設定為圖片的寬度
        const clientW = contentNode.clientWidth;
        const size = image.width / clientW;
        if (image.width > clientW) {
        canvasNode.width = clientW;
        canvasNode.height = image.height / size;
        } else {
        canvasNode.width = image.width;
        canvasNode.height = image.height;
        }
        // 呼叫drawImage API將版面圖繪製出來
        ctx.drawImage(image, 0, 0, canvasNode.width, canvasNode.height);
    });
    image.crossOrigin = 'anonymous'; // 解決圖片跨域問題
    image.src = url;
};

useEffect(() => {
    initCanvas();
}, [canvasNode, url]);

return (
    <section ref={setContentNode} className={styles.modaLLayout}>
      <canvas
        ref={setCanvasNode}
        onMouseDown={handleMouseDownEvent}
        onMouseMove={handleMouseMoveEvent}
        onMouseUp={handleMouseRemoveEvent}
      />
   </section>
)
複製程式碼

三、裁剪操作

3.1 基本裁剪流程

流程如下:

  1. 滑鼠移入canvas畫布區;
  2. 點選滑鼠,通過onMouseDown事件獲取開始座標點(startX,startY)
  3. 移動滑鼠,通過onMouseMove事件獲取座標,實時繪製裁剪框;
  4. 鬆開滑鼠,通過onMouseUp事件終止裁剪框的繪製

?實現程式碼如下:

  // 點選滑鼠事件
  const handleMouseDownEvent = e => {
    // 開始裁剪
    setDragging(true);
    const { offsetX, offsetY } = e.nativeEvent;
    // 儲存開始座標
    setStartCoordinate([offsetX, offsetY]);
    
    if (btnGroupNode == null) {
      return;
    }
    // 裁剪按鈕不可見
    btnGroupNode.style.display = 'none';
  };

  // 移動滑鼠事件
  const handleMouseMoveEvent = e => {
    if (!dragging) {
      return;
    }
    const ctx = canvasNode.getContext('2d');
    // 每一幀都需要清除畫布(取最後一幀繪圖狀態, 否則狀態會累加)
    ctx.clearRect(0, 0, canvasNode.width, canvasNode.height);

    const { offsetX, offsetY } = e.nativeEvent;

    // 計算臨時裁剪框的寬高
    const tempWidth = offsetX - startCoordinate[0];
    const tempHeight = offsetY - startCoordinate[1];
    // 呼叫繪製裁剪框的方法
    drawTrim(startCoordinate[0], startCoordinate[1], tempWidth, tempHeight);
  };

  // 鬆開滑鼠
  const handleMouseRemoveEvent = () => {
    // 結束裁剪
    setDragging(false);

    // 處理裁剪按鈕樣式
    if (curPoisition == null) {
      return;
    }
    if (btnGroupNode == null) {
      return;
    }
    btnGroupNode.style.display = 'block';
    btnGroupNode.style.left = `${curPoisition.startX}px`;
    btnGroupNode.style.top = `${curPoisition.startY + curPoisition.height}px`;

    // 判斷裁剪區是否重疊(此專案需要裁剪不規則的相鄰區域,所以裁剪框重疊時才支援批量裁剪)
    judgeTrimAreaIsOverlap();
  };
  
return (
    <section ref={setContentNode} className={styles.modaLLayout}>
      <canvas
        ref={setCanvasNode}
        onMouseDown={handleMouseDownEvent}
        onMouseMove={handleMouseMoveEvent}
        onMouseUp={handleMouseRemoveEvent}
      />
      <div ref={setBtnGroupNode} className={styles.buttonWrap}>
          <Button type="link" icon="close" size="small" ghost disabled={fileSyncUpdating} onClick={handleCancle}>
            取消
          </Button>
          <Button
            type="link"
            icon="file-image"
            size="small"
            ghost
            disabled={fileSyncUpdating}
            onClick={() => getImgTrimData('justImg')}
          >
            轉為圖片
          </Button>
          <Button
            type="link"
            icon="file-text"
            size="small"
            ghost
            loading={fileSyncUpdating}
            onClick={getImgTrimData}
          >
            轉為文字
          </Button>    
      </div>
   </section>
)
複製程式碼

3.2 繪製裁剪框

實現流程如下:

基於React Hook實現圖片的裁剪

⚠️注意: canvas是基於狀態的,save()restore()需要成對使用

如何將版面圖、蒙層、裁剪框和邊框畫素點按照順序疊在一起呢❓

這裡需要用到CanvasRenderingContext2D.globalCompositeOperation屬性,它可以實現影像的合成。

?實現程式碼如下:

// 繪製裁剪框的方法
const drawTrim = (x, y, w, h, flag) => {
    const ctx = canvasNode.getContext('2d');

    // 繪製蒙層
    ctx.save();
    ctx.fillStyle = 'rgba(0,0,0,0.6)'; // 蒙層顏色
    ctx.fillRect(0, 0, canvasNode.width, canvasNode.height);

    // 將蒙層鑿開
    ctx.globalCompositeOperation = 'source-atop';
    // 裁剪選擇框
    ctx.clearRect(x, y, w, h);
    if (!flag && trimPositionMap.length > 0) {
      trimPositionMap.map(item => ctx.clearRect(item.startX, item.startY, item.width, item.height));
    }

    // 繪製8個邊框畫素點
    ctx.globalCompositeOperation = 'source-over';
    drawBorderPixel(ctx, x, y, w, h);
    if (!flag && trimPositionMap.length > 0) {
      trimPositionMap.map(item => drawBorderPixel(ctx, item.startX, item.startY, item.width, item.height));
    }

    // 儲存當前區域座標資訊
    setCurPoisition({
      width: w,
      height: h,
      startX: x,
      startY: y,
      position: [
        (x, y),
        (x + w, y),
        (x, y + h),
        (x + w, y + h),
        (x + w / 2, y),
        (x + w / 2, y + h),
        (x, y + h / 2),
        (x + w, y + h / 2),
      ],
      canvasWidth: canvasNode.width, // 用於計算移動端版面圖縮放比例
    });

    ctx.restore();

    // 再次呼叫drawImage將圖片繪製到蒙層下方
    ctx.save();
    ctx.globalCompositeOperation = 'destination-over';
    ctx.drawImage(originImg, 0, 0, canvasNode.width, canvasNode.height);
    ctx.restore();
  };
  
// 繪製邊框畫素點的方法  
const drawBorderPixel = (ctx, x, y, w, h) => {
  ctx.fillStyle = '#f5222d';
  const size = 5; // 自定義畫素點大小
  ctx.fillRect(x - size / 2, y - size / 2, size, size);
  // ...同理通過ctx.fillRect再畫出其餘畫素點
  ctx.fillRect(x + w - size / 2, y - size / 2, size, size);
  ctx.fillRect(x - size / 2, y + h - size / 2, size, size);
  ctx.fillRect(x + w - size / 2, y + h - size / 2, size, size);

  ctx.fillRect(x + w / 2 - size / 2, y - size / 2, size, size);
  ctx.fillRect(x + w / 2 - size / 2, y + h - size / 2, size, size);
  ctx.fillRect(x - size / 2, y + h / 2 - size / 2, size, size);
  ctx.fillRect(x + w - size / 2, y + h / 2 - size / 2, size, size);
};
  
複製程式碼

四、輸出裁剪圖片

4.1 getImageData() 的使用

我們要獲取裁剪框的影像資訊,需要用到getImageData()方法,它返回一個ImageData物件。

語法
  • context.getImageData(sx, sy, sWidth, sHeight);
引數
  • sx、sy:截圖框的起始座標值;
  • sWidth, sHeight: 截圖框的寬高

❓:獲取了裁剪框影像資訊後,那怎麼將它們轉換成圖片呢 需要新建一個canvas,通過getImageData()方法把裁剪框影像資訊放在該canvas上。

❓:為什麼要新建canvas,直接用toBlob()不行嗎 HTMLCanvasElement.toBlob()是將整個canvas進行輸出,而此專案要的是canvas中裁剪框的影像資訊。

4.2 putImageData()的使用

putImageData()可以把已有的裁剪框資料繪製到新畫布的指定區域上。

語法
  • context.putImageData(imagedata, dx, dy);
  • context.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);
引數
  • imagedata:裁剪框影像資訊;
  • dx, dy: 目標Canvas中被imagedata替換的起始座標;
  • dirtyX, dirtyY:裁剪框區域左上角的座標,預設為0
  • dirtyWidth, dirtyHeight:裁剪框的寬高。預設值是imagedata影像的寬高。

4.3 使用Canvas.toDataURL()輸出圖片

canvas提供了兩個2D轉換為圖片的方法:

  • HTMLCanvasElement.toDataURL() 返回base64地址

  • HTMLCanvasElement.toBlob() 返回Blob物件

本專案OCR介面要求的圖片格式是Base64,所以使用HTMLCanvasElement.toDataURL()方法。

4.4 使用OCR識別圖片資訊

❓:為什麼要計算出包含多個裁剪框的最小矩形

因為OCR每呼叫一次都是計費的,所以不管有多少個裁剪框,最後只輸出到一個canvas上,這樣只呼叫一次OCR

⚠️單個裁剪框的最小矩形即是其本身。

❓:如何計算出最小矩形

很簡單,分別得到多個裁剪框的最小startXstartY值和最大endXendY值,即可計算出最小矩形的開始座標和寬高。

基於React Hook實現圖片的裁剪

程式碼實現如下:

// 獲得裁剪後的圖片檔案
  const getImgTrimData = flag => {
    // trimPositionMap為裁剪框的座標資料
    if (trimPositionMap.length === 0) {
      return;
    }

    const ctx = canvasNode.getContext('2d');

    // 重新構建一個canvas,計算出包含多個裁剪框的最小矩形
    const trimCanvasNode = document.createElement('canvas');
    const { startX, startY, minWidth, minHeight } = getMinTrimReactArea();
    trimCanvasNode.width = minWidth;
    trimCanvasNode.height = minHeight;
    const trimCtx = trimCanvasNode.getContext('2d');
    trimCtx.clearRect(0, 0, trimCanvasNode.width, trimCanvasNode.height);
    trimPositionMap.map(pos => {
      // 取到裁剪框的畫素資料
      const data = ctx.getImageData(pos.startX, pos.startY, pos.width, pos.height);
      // 輸出在canvas上
      return trimCtx.putImageData(data, pos.startX - startX, pos.startY - startY);
    });
    const trimData = trimCanvasNode.toDataURL();

    // 若轉成圖片,直接輸出trimData;若轉成文字,則請求OCR介面,轉換成文字
    (flag === 'justImg'
      ? Promise.resolve(trimData)
      : dispatch({
          type: 'digital/postImgFileWithAliOcr',
          payload: {
            img: trimData,
          },
        })
    ).then(result => {
       // 呼叫外部api,輸出圖片資料
      onTransform(result, flag);
    });
  };
  
  // 計算出包含多個裁剪框的最小矩形
  const getMinTrimReactArea = () => {
    const startX = Math.min(...trimPositionMap.map(item => item.startX));
    const endX = Math.max(...trimPositionMap.map(item => item.startX + item.width));
    const startY = Math.min(...trimPositionMap.map(item => item.startY));
    const endY = Math.max(...trimPositionMap.map(item => item.startY + item.height));
    return {
      startX,
      startY,
      minWidth: endX - startX,
      minHeight: endY - startY,
    };
  };  
複製程式碼

總結

很多業務場景中會用到圖片的裁剪功能,因為裁剪元件實現起來比較費時間,所以很多前端朋友直接藉助第三方外掛,但外掛中又依賴了很多別的外掛,這樣你的專案後期維護會比較費勁,個人建議能不依賴第三方庫的儘量自己去實現。

本文主要是介紹裁剪框的繪製,至於裁剪框的移動、伸縮、旋轉,暫沒有去實現,這些都是基於座標點的操作,相對簡單。

Canvas的屬性和方法若能用得好的話,可以實現非常多好玩的效果,前提是要吃透canvas

歡迎指正,謝謝!

參考連結

相關文章