跳到主要内容

裁剪工具

如何做出一个完整的图片裁剪组件? 背景 一个图片裁剪组件的应用场景其实比较多,相应的第三方插件也不少,但有时候会需要一些特定的功能(比如想有个特定样式的裁剪框,想批量裁剪,甚至想直接裁出需求的最优尺寸等等),这时就只能手写一个裁剪组件了。

大致流程 300

关于为什么会需要一个传入图片链接的入口,是因为会有一些编辑上传信息的场景,就比如需要修改已经发布过的商品信息时,图片是已经上传过的,只有一个图片链接,所以这时就需要根据这个图片链接来裁剪图片了。 由于本组件是基于react开发的,所以下文的写法都会是react的方式。并且因为篇幅有限,涉及的内容比较多,只介绍了大致思路和实现,若有错误还请见谅。。 一、读取图片 本地图片上传 本地上传直接使用 input type="flie" 即可,multiple用于控制是否批量上传,accept用于控制上传文件的类型,设置为image/*表示接收所有image后缀的文件,onChange用于监听上传事件,在这里可以做一些上传时的规则校验(比如图片数量,图片大小限制等等)

<input
type="file"
onChange={this.handleChange}
multiple="true"
accept="image/*"
ref={e => {
this.imageUpload = e;
}}
/>

读取文件信息 首先,html5中的 input 上传的是一个file对象,里面记录了文件的name,size,type,和修改时间等(只读),可以用onChange事件来通过e.target.files来获取(注意:ie10以下不兼容target),在预览图片之前可以通过这些信息来限制一下上传图片的size、type等。

handleChange = (e) => {
const files = Array.from(e.target.files);
if (!files.length) {
// 释放系统存储当前值,避免相同文件不触发onchange事件
this.imageUpload.value = null;
return;
}
// 上传规则校验
....
}

注意点: 在 input 使用onChange上传文件时,如果连续两次选择相同的文件,第二次会因为value还是同一个值导致onChange不会触发,所以需要在第一次上传完之后,将input的value置为空。

二、Canvas绘制图片 解析图片信息 利用我们刚刚获取的file文件对象,来解析出一些图片的关键信息,比如图片的宽、高以及最重要的base64。主要是通过FileReader.readAsDataURL来实现,并实例出一个canvas需要的图片Image对象。

// 读取图片原始信息方法
filesInfo: function(file) {
return new Promise((res, rej) => {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function() {
let image = new Image(); // 实例一个Image对象
let _this = this;
image.onload = function() {
res({
link: _this.result, // base64
width: image.width, // 宽
height: image.height, // 高
...其他图片信息
});
};
image.src = _this.result; // base64
image.crossOrigin = 'Anonymous'; //解决跨域问题
};
});
},

当 FileReader 的实例通过 readAsDataURL 方法把图片file转换为 base64 格式之后,会触发 onload 方法,返回一个基于 base64 编码的 data-uri 对象,此方法内的 event.target.result 就是所需要的 base64 格式。

除了上述的其实还有第二种方式, window.URL.createObjectURL,功能都差不多,本文就不做赘述了。

预览图片 理论上我们拿到刚刚获取的Image对象之后,可以直接添加到dom中去就能实现图片预览,但我们核心是裁剪(硬气),所以就要放入canvas画布中去,主要通过canvas的drawImage来绘制图片。

而为什么刚刚我们还需要获取图片的宽、高呢?是因为裁剪框通常是一个固定宽高的画布,但图片的大小往往五花八门,就需要自适应这些图片。

首先canvas的宽高分为2种,第一种canvas style样式中的宽高,是整个canvas的宽高,决定了整个元素的大小;而第二种写在canvas标签属性内的宽高,表示canvas内部的画布大小。

因此我们的自适应图片居中策略:

给canvas元素外设置一个外壳div,并设置默认宽高,用来固定整个canvas的变化范围,让其始终保持居中。 根据我们刚刚获取的图片信息,计算出宽高比例proportion。 再结合我们外壳div的默认宽高,计算出scale,最终动态来得出这个canvas的宽高。

// 绘制图片方法
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 * this.ratio,
canvasHeight = image.height * scale * this.ratio;
this.canvasRef.width = canvasWidth;
this.canvasRef.height = canvasHeight;
this.canvasRef.style.width = canvasWidth / this.ratio + 'px';
this.canvasRef.style.height = canvasHeight / this.ratio + '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%);
}
}

为什么canvas绘制的图好模糊呢? 这就涉及到像素比devicePixelRatio的问题了,一般在绘制之前都会提前计算好这个值,具体可以戳:https://www.jianshu.com/p/4c4312dc7fc5

三、裁剪相关操作

Canvas的save() 和 restore() 在实现裁剪步骤之前,我们需要先了解一下canvas的save和restore分别是用来做什么的。

首先根据MDN上的解释:

CanvasRenderingContext2D.save() 是 Canvas 2D API 通过将当前状态放入栈中,保存 canvas 全部状态的方法。

CanvasRenderingContext2D.restore() 是 Canvas 2D API 通过在绘图状态栈中弹出顶端的状态,将 canvas 恢复到最近的保存状态的方法。 如果没有保存状态,此方法不做任何改变。

通俗地说,save和restore是用来保存canvas状态的存储器,context.save()将当前状态压入堆栈。context.restore()弹出堆栈顶端的状态,将上下文恢复到该状态。

那么什么是canvas的状态呢?这里有一个容易被误解的点,状态并不是指画布的内容,而是画布的绘制属性,就比如:

当前的矩阵变换:平移translate(),缩放scale(),以及旋转rotate()等 当前的剪切区域:clip() 其他属性值:strokeStyle,fillStyle,lineWidth,shadowColor ...等 那么我们了解这个属性有什么作用?因为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); //填充默认的黑色
}

上述代码绘制第一个正方形时,我们填充了蓝色,而第二个正方形没有设置颜色,所以是默认的黑色,但是如果将上文的save和restore注释掉,绘制的就都是蓝色的正方形了,这是因为fillStyle改变了canvas的绘制属性,如果不进行restore回到之前的绘制属性,那之后绘制的就都是蓝色了,另外save()和restore()都是成双成对的,千万不要拆散他们

300*300

基本裁剪流程 回归正题,关于图片裁剪,我们一般的操作流程是,鼠标移入预览图片中,通过点击并移动鼠标画出一个方形框,松开之后结束方形框的绘制。

所以一个完整的裁剪大致思路为:

获取鼠标点击时的位置,通过dragging一个变量来判断是否触发裁剪,并且点击之后将dragging置为true 鼠标移动时用dragging来判断是否绘制裁剪选择框 鼠标移出画布或者松开之后将dragging置为false 分别通过onMouseDown、onMouseMove、onMouseUp三种事件监听来完成一次完整的裁剪选择框绘制操作。

// 点击事件
mouseDownEvent = (e) => {
// 用来判断是否触发裁剪
this.dragging = true;
// 保存当前鼠标开始坐标, 坐标都会乘以个像素比
this.startX = e.nativeEvent.offsetX;
this.startY = e.nativeEvent.offsetY;
}
// 移动事件
mouseMoveEvent = (e) => {
// 计算临时裁剪框的宽高
let tempWidth = e.nativeEvent.offsetX - this.startX,
tempHeight = e.nativeEvent.offsetY - this.startY;
if (!this.dragging) return
// 调用绘制裁剪框的方法
this.drawTrim(this.startX, this.startY, tempWidth, tempHeight, this.showImg)
}
// 移出/松开事件
mouseRemoveEvent = (e) => {
// 保存相关裁剪选择框信息
if (this.dragging) { ... }
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>
// ...
)
}

裁剪框的绘制 关于裁剪框的展现形式,业界里比较常用的方式是先在canvas画布绘制一个蒙层,然后把裁剪选择框的蒙层部分镂空,再绘制8个可hover的像素点,最后再把图片绘制在蒙层的下方,大概就是介个样子的:

300*300

所以我们需要通过刚刚获取的鼠标坐标来绘制一个裁剪框,并保存相关裁剪框的坐标信息(下文使用trimPosition表示),以及8个边框像素点的坐标信息以及对应的事件参数(下文使用borderArr表示)

// 绘制裁剪框方法
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();
}

裁剪框的移动和伸缩 光实现了裁剪选择框还不够,我们平时的裁剪步骤还需要移动以及自由拉伸,怎么实现呢?我们上文已经通过drawTrim这个方法绘制出了初次的裁剪框,那也就是说我们裁剪框的每次移动、伸缩,都可以通过drawTrim这个方法来重绘,我们需要的只是移动、伸缩之后的坐标信息罢了。

如何获取这些坐标信息呢?毫无疑问,就是通过刚刚那8个边框像素点的信息来触发的。所以我们这里的大致思路为:

鼠标再次移入预览图片中时,增加区分是处于裁剪选择框内还是选择框外的判断,如果是处于裁剪选择框内(8个像素点区域中),需要 记录/保存 当前相应的事件参数borderOption 当鼠标再次点击时,通过事件参数borderOption是否有值来判断是处于裁剪过程还是操作裁剪框的过程,如果是操作裁剪框的过程,需要新增一个变量开关draggingTrim来判断是否执行相应的事件 鼠标点击移动时,如果是操作裁剪框过程,也就是draggingTrim为true时,则从borderOption获取相应触发的事件参数,修改坐标,重新绘制裁剪框 大致实现如下:

我们之前在onMouseMove事件里绘制裁剪框时,已经保存了8个边框像素点的坐标信息以及对应的事件参数(下文使用borderArr表示),所以我们只要在onMouseMove鼠标移动事件时,通过ctx.isPointInPath来判断鼠标是否移入了8个像素点的区域,并记录当前相应的事件参数(下文使用borderOption表示)

// 移动事件
mouseMoveEvent = (e) => {
// 计算临时裁剪框的宽高
// ...

// 判断鼠标位置
// 当前坐标
let currentX = e.nativeEvent.offsetX,
currentY = e.nativeEvent.offsetY;
ctx.beginPath();
for (var i = 0; i < this.borderArr.length; i++) {
// ...
if (ctx.isPointInPath(currentX, currentY)) {
// 如果鼠标移入了裁剪选择框的区域
switch (this.borderArr[i].index) {
// 鼠标手势变化
// ...
}
// 将当前所移入区域的鼠标事件参数记录下来
this.borderOption = this.borderArr[i].option;
break;
}
}
ctx.closePath();

// 调用绘制裁剪框的方法
// ...
}
在onMouseDown鼠标点击时,如果borderOption有值就代表当前鼠标处于裁剪框的区域,然后我们通过一个变量开关draggingTrim来判断是处于裁剪过程还是操作裁剪框的过程
// 点击事件
mouseDownEvent = (e) => {
// ...
if (this.borderOption) {
// 如果操作的是裁剪框
this.draggingTrim = true;
// 同样保存当前鼠标开始坐标信息, 用于之后重新计算裁剪框
this.moveStartX = e.nativeEvent.offsetX;
this.moveStartY = e.nativeEvent.offsetY;
} else {
// 保存当前鼠标开始坐标, 坐标都会乘以个像素比
// ...
}
}
如果是操作裁剪框过程,也就是draggingTrim为true,则从borderOption获取相应触发的事件参数,修改坐标,重新绘制裁剪框
// 移动事件
mouseMoveEvent = (e) => {
// 计算临时裁剪框的宽高
// ...

// 判断鼠标位置
// ...

// 操作裁剪框
if (this.draggingTrim) {
// 获取相应位置的事件参数,计算新裁剪框的坐标并绘制
// ...
let { tempStartX, tempStartY, tempWidth, tempHeight } = this.borderOption
this.drawTrim(tempStartX, tempStartY, tempWidth, tempHeight, this.showImg);
}

// 调用绘制裁剪框的方法
// ...
}

重新绘制的裁剪框坐标其实还需要根据鼠标开始的坐标来计算,这里具体8种方向的计算方法就不做赘述了,只是提供一种思路。

旋转 其实我个人觉得裁剪组件中最难计算的就是这个旋转坐标,我们先了解一下canvas的rotate方法吧

我们都知道,canvas的初始画布的坐标轴原点在左上角,也就是说(0,0)代表了左上角的那个点,基于左上角往右 X 为正,往下 Y 为正,反之为负。

而canvas中的rotate方法就是绕画布左上角(0,0)进行旋转的,而且坐标轴也会旋转,并且会受到translate的影响,也就是说我们如果通过rotate方法顺时针旋转90度,图片在画布中的相对位置是会改变的,坐标轴也会从“右X为正,下Y为正”变成“下X为正,左Y为正”。

那么我们该如何实现图片以自身为中心旋转呢?这个时候就得提一下canvas的translate了,顾名思义,就是用来平移画布坐标轴原点的方法。每次旋转之后再将坐标轴平移回原来的位置是不是就可以了?

重新理一下思路,如果我们需要实现图片以自身为中心旋转45度:

将canvas的坐标轴原点平移到这张图的中心 旋转canvas 45度 绘制图片时再将图片往右上角平移图片自身一半的距离 300*300

记得每次旋转完之后还需要用上文提到的save和rotate恢复到之前的绘制属性状态,这里只介绍一下旋转的大致思路,具体可以戳:https://www.cnblogs.com/suyuanli/p/8279244.html

尺寸输入裁剪、尺寸优化 其实这些类似定制裁剪选择框的功能,思路都是一样的,都是通过一些计算来获取最终坐标信息,再去用drawTrim这个方法来重新绘制出一个裁剪选择框,所以这部分就不再详细介绍了。

四、输出裁剪图片 使用Canvas.toBlob() 输出图片 我们在上传图片的时候,是将file文件转成了base64,再利用canvas的drawImage()来实现的图片预览,那么我们裁剪完之后如何将canvas转回img图片呢?

其实canvas提供了两个2D转换为图片的方法:canvas.toDataURL()和canvas.toBlob(),由于我们最终的目的是上传至CDN,所以这里选择使用canvas.toBlob()这个方法:

canvas.toBlob(callback, type, encoderOptions);

可以看到第一个参数是callback()回调函数,这个函数默认的第一个参数就是blob对象

// 获得裁剪后的图片文件
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 }) 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,这里简称canvas2 使用getImageData将原先canvas的裁剪框中的像素数据导出 使用putImageData把像素数据放回在刚刚构建的canvas2中,再进行toBlob方法输出图片

// 获得裁剪后的图片文件
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 上传至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等,具体可以戳:https://juejin.im/post/5c46b58cf265da617265c876 总结 本文主要介绍了一个完整的裁剪过程的大致实现,至于一些比较定制的功能(批量裁剪、缩放裁剪、尺寸优化等),原理其实都大同小异,只是如何存储批量的图片信息、裁剪信息的问题罢了

操作canvas最重要的一点就是关于坐标的计算,尤其是旋转的坐标,一定要细心地理清楚。其实整个流程下来,只要思路清晰,还是挺简单的。

参考链接