从零开始作一个图片裁剪组件

零、 介绍

本篇文章主要介绍如何从零开始作一个完整的图片裁剪组件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

参考连接

相关文章
相关标签/搜索