基於React Hook實現圖片的裁剪

項目背景

最近作了個電子報項目,用戶可在上傳的報刊版面圖上劃出一個個區域,經過OCR圖文識別技術,識別出區域文字信息,而後編輯成一條條新聞,可在PC端和手機端點擊版面圖,查看新聞詳情。前端

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

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

單個裁剪redux

批量裁剪canvas

Canvas技術點

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

🔥小貼士:若是您對本文有興趣,指望您先了解以上技術點。api

流程簡介

  1. 讀取圖片跨域

  2. Canvas繪製圖片bash

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

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

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

1、讀取圖片

組件初始化時,經過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]);
}    
複製代碼

2、canvas繪製圖片

2.1 drawImage()的使用

語法
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、裁剪操做

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 繪製裁剪框

實現流程以下:

⚠️注意: 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、輸出裁剪圖片

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值,便可計算出最小矩形的開始座標和寬高。

代碼實現以下:

// 得到裁剪後的圖片文件
  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

歡迎指正,謝謝!

參考連接

相關文章
相關標籤/搜索