從零開始作一個圖片裁剪組件

零、 介紹

本篇文章主要介紹如何從零開始作一個完整的圖片裁剪組件javascript

本文主要包括:css

  1. 上傳讀取圖片
  2. Canvas繪製圖片
    1. 解析圖片信息
    2. 預覽圖片
  3. 裁剪相關操做
    1. Canvas的save() 和 restore()
    2. 基本裁剪流程
    3. 裁剪框的繪製
    4. 裁剪框的移動和伸縮
    5. 旋轉
  4. 輸出裁剪圖片
    1. 使用Canvas.toBlob() 輸出圖片
    2. Canvas的getImageData() 和 putImageData()
    3. 上傳至CDN

背景

一個圖片裁剪組件的應用場景其實比較多,相應的第三方插件也很多,但有時候會須要一些特定的功能,好比想有個特定樣式的裁剪框,想批量裁剪,甚至想直接裁出定制化的尺寸等等,這時就只能手寫一個裁剪組件了。html

大體流程

300

1、上傳讀取圖片

上傳圖片時,用onChange事件來獲取該file對象,裏面會包含文件的name,size,type,和修改時間等信息(只讀),預覽圖片以前能夠經過這些信息來限制上傳圖片的格式、類型等等。java

handleChange = (e) => {
        const files = Array.from(e.target.files);
        if (!files.length) {
            // 釋放上傳系統存儲當前值,避免相同文件不觸發onchange事件
            this.imageUpload.value = null; 
            return;
        }
        // 上傳規則校驗(好比圖片格式,圖片大小限制等等
        ....
}
render() {
  return (
  	<div>
      	<input
            type="file"
            onChange={this.handleChange} // 監聽上傳事件
            multiple="true" // 是否批量上傳
            accept="image/*" // 控制上傳文件的類型,image/*表示接收全部image後綴的文件
            ref={e => {
                this.imageUpload = e;
            }}
        />
    </div>
  )
}
複製代碼

⚠️注意點: 使用onChange上傳文件時,若是連續兩次選擇相同的文件,第二次會由於value仍是同一個值致使onChange不會觸發,因此在第一次上傳完以後,須要將input的value置爲空react

2、Canvas繪製圖片

2.1 解析圖片信息

利用咱們剛剛獲取的file文件對象,能夠解析出一些圖片的關鍵信息,好比圖片的寬、高以及最重要的base64。這裏咱們主要是經過FileReader.readAsDataURL來實現。git

1

觸發 FileReader.onload 方法時,會返回一個基於 base64 編碼的 data-uri 對象。github

// 讀取圖片原始信息方法
filesInfo = (file) => {
    return new Promise((res, rej) => {
        let reader = new FileReader();
        reader.readAsDataURL(file);
        reader.onload = function(e) {
          	// 實例一個Image對象,爲了獲取寬、高(下文預覽圖片時須要)
          	let image = new Image(); 
            image.onload = function() {
                res({
                    width: image.width, // 寬
                  	height: image.height, // 高
                    // 其餘圖片信息
                  	// ...
                });
            };
            image.src = e.target.result; // base64
          	image.crossOrigin = 'Anonymous'; //解決跨域問題
        };
    });
},
複製代碼

其實除了上述還有第二種方式, window.URL.createObjectURL,有興趣的小夥伴能夠自行查閱一下,本文就再也不作贅述了。canvas

2.2 預覽圖片

canvas的寬高分爲2種:api

  • canvas style樣式中的寬高:是整個canvas的寬高,決定了整個canvas context的大小跨域

  • canvas元素屬性的寬高:表示canvas的畫布大小

所以咱們的自適應圖片居中策略:

300*300

⚠️關於設備像素比具體能夠戳 👉 爲何canvas繪製的圖很模糊❓

canvas渲染圖片的主要是經過canvas.drawImage(),🔨部分實現代碼以下:

// 繪製圖片方法
// 這裏的參數就是上文的image對象
drawImage = (image) => {
  	// 獲取canvas的上下文
  	this.showImg = this.canvasRef.getContext('2d');
  	// 清除畫布
  	this.showImg.clearRect(0, 0, this.canvasRef.width, this.canvasRef.height);
  	// 設置默認canvas元素大小
  	const canvasDefaultSize = 300;
    // 初始化canvas畫布大小, 獲取等比例縮放後的canvas寬高尺寸
  	let proportion = image.width / image.height,
      	scale = proportion > 1 ? canvasDefaultSize / image.width : canvasDefaultSize / image.height,
        canvasWidth = image.width * scale * 像素比,
        canvasHeight = image.height * scale * 像素比;
    this.canvasRef.width = canvasWidth;
    this.canvasRef.height = canvasHeight;
    this.canvasRef.style.width = canvasWidth / 像素比 + 'px';
  	this.canvasRef.style.height = canvasHeight / 像素比 + 'px';
  	// ...
    // 繪製圖片,這個image就是咱們剛剛獲取的Image對象
  	this.image = image; // 保存這個Image對象
    this.showImg.drawImage(image, 0, 0, this.canvasRef.width, this.canvasRef.height);
};
render() {
  const canvasDefaultSize = 300; // 設置默認canvas元素大小
  return (
  	<div 
      className="modal-trim"
      // 固定整個canvas的變化範圍
      style={{ width: `${canvasDefaultSize}px`, height: `${canvasDefaultSize}px` }}    
    >
      	<canvas 
          ref={e => {this.canvasRef = e}} 
          // 給予一個默認初始寬高
          width={canvasDefaultSize}
          height={canvasDefaultSize}
          // ...
       	></canvas>
    </div>
  )
}
複製代碼
/* 部分css */
.modal-trim {
    overflow: hidden;
    position: relative;
  	/* 馬賽克背景圖 */
    background-image: url(https://s10.mogucdn.com/mlcdn/c45406/190723_3afckd96l9h4fh6lcb56117cld176_503x503.jpg);
    background-size: cover;
  	/* 使canvas始終居中 */
    canvas {
        cursor: default;
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%,-50%);
    }
}
複製代碼

3、裁剪相關操做

3.1 Canvas的save() 和 restore()

(瞭解canvas的save()和restore()的童鞋能夠直接跳過這小節)

通俗地說,save和restore是用來保存canvas狀態的存儲器

  • context.save()將當前狀態壓入堆棧。
  • context.restore() 彈出堆棧頂端的狀態,將上下文恢復到該狀態。

那麼什麼是canvas的狀態呢?這裏有一個容易被誤解的點,狀態並非指畫布的內容,而是畫布的繪製屬性,好比:

  • 當前的矩陣變換:平移translate(),縮放scale(),以及旋轉rotate()
  • 當前的剪切區域:clip()
  • 其餘屬性值:strokeStylefillStylelineWidthshadowColor ...等

因此咱們瞭解這個屬性有什麼做用?由於canvas的上下文只有一個,咱們進行裁剪操做的時候會涉及到大量的狀態變換,好比裁剪選擇框的重繪,圖片的旋轉等,在進行這些操做的時候就須要恢復繪製屬性,舉個🌰:

function draw() {
   let ctx = document.getElementById("canvas").getContext("2d");
   ctx.save();  //默認設置
   ctx.fillStyle = "#09f";
   ctx.fillRect(15,15,120,120); //填充當前設置的#09f顏色
   ctx.restore();
   ctx.fillRect(30,30,90,90); //填充默認的黑色
}
複製代碼

上述代碼繪製第一個正方形時,咱們填充了藍色,而第二個正方形沒有設置顏色,因此是默認的黑色。效果以下:

300*300

可是若是將上文的save和restore註釋掉,繪製的就都是藍色的正方形了,這是由於fillStyle改變了canvas的繪製屬性,若是不進行restore恢復以前的繪製屬性,那以後繪製的就都是藍色了。

⚠️注意點: save()和restore()都是成雙成對的,千萬不要拆散他們

3.2 基本裁剪流程

迴歸正題,關於圖片裁剪,咱們通常的操做流程是:

300*300

因此咱們能夠經過鼠標的**onMouseDown(點擊)、onMouseMove(移動)、onMouseUp(鬆開)**三種事件監聽,來完成一次完整的裁剪操做。🔨部分實現代碼以下:

// 每張圖片的初始化配置
initialConfigs = () => {
  this.showImg = this.canvasRef.getContext('2d');
  this.dragging = false; // 判斷是否觸發裁剪操做的全局變量
  this.startX = null;
  this.startY = null;
}

// 點擊事件
mouseDownEvent = (e) => {
  	// 點擊時表示觸發裁剪操做
    this.dragging = true;
  	// 保存當前鼠標開始座標, 通常座標都會乘以個像素比
  	this.startX = e.nativeEvent.offsetX;
  	this.startY = e.nativeEvent.offsetY;
}

// 移動事件
mouseMoveEvent = (e) => {
    if (!this.dragging) return;
    // 計算臨時裁剪框的寬高
    let tempWidth = e.nativeEvent.offsetX - this.startX,
        tempHeight = e.nativeEvent.offsetY - this.startY;
    // 調用繪製裁剪框的方法
  	this.drawTrim(this.startX, this.startY, tempWidth, tempHeight, this.showImg)
}

// 移出/鬆開事件
mouseRemoveEvent = (e) => {
  	// 保存相關裁剪選擇框信息
    if (this.dragging) { ... }
    // 保存後將其置爲false,表示結束當前流程
    this.dragging = false;
}
render() {
  return (
  	// ...
      	<canvas 
          ref={e => {this.canvasRef = e}}
          onMouseDown={(e) => this.mouseDownEvent(e)}
          onMouseMove={(e) => this.mouseMoveEvent(e)}
          onMouseUp={(e) => this.mouseRemoveEvent(e)}
        ></canvas>
   // ...
  )
}
複製代碼

3.3 裁剪框的繪製

關於裁剪框的繪製實現,業界裏比較經常使用的方式大概是介個樣子:

300*300

如何將這幾層圖像按照需求正確疊在一塊兒呢❓

這裏就須要用到canvas.globalCompositeOperation這個API了,它設置或返回新圖像如何繪製到已有的圖像上,來合併圖片實現裁剪框。關於它繪製的具體參數能夠戳 👉 globalCompositeOperation詳解 或者 MDN

利用咱們須要剛剛傳過來的鼠標座標參數,咱們來繪製裁剪框以及8個邊框像素點,記得必定要保存每次操做的相關信息~ 🔨部分實現代碼以下:

// 每張圖片的初始化配置
initialConfigs = () => {
  // ...
  // 須要保存的座標信息
  this.trimPosition = { 
    startX: null,
    startY: null,
    width: null,
    height: null
  };	// 裁剪框座標信息
  this.borderArr = []; // 裁剪框邊框節點座標
  this.borderOption = null; // 裁剪框邊框節點事件
}

// 繪製裁剪框方法
drawTrim = (startX, startY, width, height, ctx) => {
    // 每一幀都須要清除畫布
    ctx.clearRect(0, 0, this.canvasRef.width, this.canvasRef.height);
  
    // 繪製蒙層
    ctx.save();
    ctx.fillStyle = 'rgba(0,0,0,0.6)'; // 蒙層顏色
    ctx.fillRect(0, 0, this.canvasRef.width, this.canvasRef.height);
  
    // 將蒙層鑿開
    ctx.globalCompositeOperation = 'source-atop';
    ctx.clearRect(startX, startY, width, height); // 裁剪選擇框
  
  	// 繪製8個邊框像素點並保存座標信息以及事件參數
    ctx.globalCompositeOperation = 'source-over';
  	ctx.fillStyle = '#fc178f';
    let size = 10; // 自定義像素點大小
  	ctx.fillRect(startX - size / 2, startY - size / 2, size, size);
  	// ...同理經過ctx.fillRect再畫出8個像素點
    ctx.restore();
  
    // 再次使用drawImage將圖片繪製到蒙層下方
    ctx.save();
    ctx.globalCompositeOperation = 'destination-over';
   	ctx.drawImage(this.image, 0, 0, this.canvasRef.width, this.canvasRef.height);
    // ...
    ctx.restore();
}
複製代碼

3.4 裁剪框的移動和伸縮

光實現了裁剪選擇框還不夠,咱們平時的裁剪步驟還須要移動以及自由拉伸,怎麼實現呢❓

咱們上文已經經過drawTrim()這個方法繪製出了初次的裁剪框,而咱們裁剪框的每次移動、伸縮,修改的只是裁剪框移動、伸縮以後的座標信息,也就是說均可以經過drawTrim()這個方法來重繪。

因此咱們這裏須要修改一下上文的基本裁剪流程:

300*300

🔥小貼士:這裏可使用canvas.isPointInPath()來判斷鼠標是否移入了8個像素點的區域

主要仍是依靠咱們上文保存的裁剪框的座標以及8個像素點座標信息,來判斷當前須要執行的事件。

相似的,若是咱們須要有定向修改裁剪框大小直接裁出需求的最優尺寸等定製化的功能,思路也是同樣的,都是經過一些計算來獲取裁剪框最終座標信息,再去用上文的drawTrim()這個方法來重繪出一個裁剪框。

3.5 旋轉

裁剪組件中最坑的點就是這個旋轉座標🔨,咱們先了解一下canvas.rotate()

衆所周知,canvas的初始畫布的座標軸原點在左上角,也就是說(0,0)表明了左上角的那個點,基於左上角往右 X 爲正,往下 Y 爲正,反之爲負。

canvas中的rotate方法就是繞畫布左上角(0,0)進行旋轉的,並且座標軸也會旋轉,而且會受到translate的影響,也就是說咱們若是經過rotate方法順時針旋轉90度,圖片在畫布中的相對位置是會改變的,座標軸也會從「右 X 爲正,下 Y 爲正」變成「下 X 爲正,左 Y 爲正」。

300*300

因此咱們該如何實現圖片以自身爲中心旋轉呢❓

這個時候就得提一下canvas.translate()了,顧名思義,就是用來平移畫布座標軸原點的方法。每次旋轉以後再將座標軸平移回原來的位置是否是就能夠了?

從新理一下思路,若是咱們須要實現圖片以自身爲中心旋轉45度:

  1. 將canvas的座標軸原點平移到這張圖的中心
  2. 旋轉canvas 45度
  3. 繪製圖片時再將圖片往右上角平移圖片自身一半的距離
300*300

⚠️注意點:記得每次旋轉完以後還須要用上文提到的save和rotate恢復到以前的繪製屬性狀態,因爲涉及到的代碼仍是座標的計算與轉換,這裏只介紹一下旋轉圖片的大體思路,想具體瞭解能夠戳 👉 canvas旋轉詳解

4、輸出裁剪圖片

4.1 使用Canvas.toBlob() 輸出圖片

咱們在上傳圖片時,將file文件轉成了base64,再利用canvas.drawImage()來實現的圖片預覽,那麼咱們裁剪完以後如何將canvas轉回img圖片呢❓

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

  • canvas.toDataURL()

  • canvas.toBlob()

因爲咱們最終的目的是上傳至CDN,因此這裏選擇canvas.toBlob()這個方法:

// 得到裁剪後的圖片文件
getImgTrim = (type) => {
  	this.canvasRef.toBlob((blob)=>{
        // 加個時間戳緩存
        blob.lastModifiedDate = new Date();
      	let fd = new FormData();
      	fd.append('image', blob);
      	// 圖片上傳cdn
      	// ...
    }, type)
}
複製代碼

❓:若是我須要轉成的是file對象怎麼辦

const file = new File([blob], '圖片.jpg', { type: blob.type })
複製代碼

4.2 Canvas的getImageData() 和 putImageData()

咱們先來看看MDN上是如何解釋的:

  • CanvasRenderingContext2D.getImageData() 返回一個ImageData對象,用來描述canvas區域隱含的像素數據,這個區域經過矩形表示,起始點爲*(sx, sy)、寬爲sw、高爲sh*
  • CanvasRenderingContext2D.putImageData() 是 Canvas 2D API 將數據從已有的 ImageData 對象繪製到位圖的方法。 若是提供了一個繪製過的矩形,則只繪製該矩形的像素。此方法不受畫布轉換矩陣的影響。

通俗的說,getImageData()是用來獲取canvas畫布區域的像素數據,並返回一個ImageData對象的,而putImageData()則是將ImageData對象的像素數據放回canvas畫布中。

因此咱們爲何須要了解這兩個api呢?直接canvas.toBlob()輸出圖片不就好了❓

由於canvas.toBlob()輸出的是canvas整個畫布元素,而不是咱們所裁剪的部分,因此咱們須要新構建一個canvas畫布來實現:

300*300
// 得到裁剪後的圖片文件
getImgTrim = (type) => {
  	// 從新構建一個canvas
  	this.saveImg = this.saveCanvasRef.getContext('2d');
  	this.saveImg.clearRect(0, 0, this.saveCanvasRef.width, this.saveCanvasRef.height);
  	// 裁剪框的像素數據
  	let { startX, startY, width, height } = this.trimPosition
  	const data = this.canvasRef.getImageData(startX, startY, width, height)
    // 輸出在另外一個canvas上
    this.saveImg.putImageData(data, 0, 0)
  	this.saveCanvasRef.toBlob((blob)=>{
        // ...
    }, type)
}
複製代碼

❓:爲何我輸出的圖片總體變大/變小了

雖然咱們將裁剪框中的圖片部分截取到第二個canvas上了,但咱們canvas自己的寬高是通過計算後的(在預覽圖片那一段中),和圖片自己的寬高是不一致的,就致使了輸出的圖片總體變大/變小。

解決辦法:我這裏是新建了第三個canvas畫布做爲承接,思路以下:

300*300

4.3 上傳至CDN

既然要作一個通用型組件,最好就是要統一成同一個出口,因此咱們這裏選擇上傳至CDN,統一輸出爲圖片連接

// 得到裁剪後的圖片文件
getImgTrim = (cdnUrl, type) => {
  	// 從新構建一個canvas並輸出
  	// ...
  	this.saveCanvasRef.toBlob((blob)=>{
        // 加個時間戳緩存
        blob.lastModifiedDate = new Date();
      	let fd = new FormData();
      	fd.append('image', blob);
      	// 建立 XMLHttpRequest 提交對象
      	let xhr = new XMLHttpRequest();
      	xhr.onreadystatechange = function () {
          	if (this.readyState === 4 && this.status === 200) {
              	// ...
            }
        }
      	// 開始上傳
        xhr.withCredentials = true; // 跨域傳cookie的時候有用
        xhr.open("POST", cdnUrl, true);
        xhr.setRequestHeader('Access-Control-Allow-Headers','*');
        xhr.send(fd);
    }, type)
}
複製代碼

❓:爲何我輸出圖片以後會報跨域的錯誤

canvas在輸出圖片時會因畫布污染致使跨域,須要設置crossOrigin爲 'Anonymous'以及setRequestHeader等,具體能夠戳 👉 解鎖canvas導出圖片跨域的N種姿式

總結

本文主要介紹了一個完整的裁剪過程的大體實現,至於一些比較定製的功能(批量裁剪縮放裁剪定向尺寸裁剪等),原理其實都大同小異,只是如何操做批量的圖片信息、裁剪信息的問題罷了

操做canvas最重要的一點就是關於座標的計算,尤爲是旋轉的座標,必定要細心地理清楚。其實整個流程下來,只要思路清晰,仍是挺簡單的。

組件demo地址能夠戳 👉 github

參考連接

相關文章
相關標籤/搜索