SimpleCrop 媲美原生体验的Web图片裁剪组件

SimpleCrophttps://github.com/newbieYoung/Simple-Crop 是一个在功能交互上复刻移动设备原生图片裁剪功能的Web图片裁剪组件;

左侧是 IOS 系统相册中原生的图片裁剪功能,右侧为 SimpleCrop 的示例二。

可以扫描二维码体验:

或者访问以下链接:

https://newbieyoung.github.io/Simple-Crop/test-2.html

之所以会做这个项目主要是因为已知的图片裁剪组件并不能完全满足自己的要求,比如:

只支持旋转固定角度。

完全不支持旋转。

因此和目前流行的 Web 图片裁剪组件相比,其优势在于以下几点:

  • 1、裁剪图片支持任意角度旋转;
  • 2、支持边界判断、当裁剪框里出现空白时,图片自动吸附至完全填满裁剪框;
  • 3、裁剪框位置支持偏移(可以不用固定在页面中心)。

实现

要实现同时支持任意角度旋转边界判断,需要解决以下几何问题;

1. 判断点和矩形的位置关系

1
2
a1 + a2 + a3 + a4 < 360
b1 + b2 + b3 + b4 = 360

判断点是否在矩形外可以通过连接矩形四个顶点和判断点,然后计算四条连线之间的夹角,如果夹角之和小于 360 度,那么该判断点在矩形外;反之如果夹角之和等于 360 度,那么该判断点在矩形内。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//根据角度和判断点是否在矩形内
SimpleCrop.prototype.isPointInRectCheckByAngle = function(point,rectPoints){
//先计算四个向量
var vecs = [];
for(var i=0; i<rectPoints.length; i++){
var p = rectPoints[i];
vecs.push({x:(p.x - point.x),y:(p.y - point.y)});
}

//计算模最小向量
var sIndex = 0;
var sLen = 0;
for(var i=0; i<vecs.length; i++){
var len = this.vecLen(vecs[i]);
if(len==0||len<sLen){
sIndex = i;
sLen = len;
}
}
var len = vecs.length;
var sVec = vecs.splice(sIndex,1)[0];
var tVec = sVec;
var eVec;

//依次计算四个向量的夹角
var angles = [];
for(i=1;i<len;i++){
var data = this.getMinAngle(tVec,vecs);
tVec = data.vec;
vecs.splice(data.index,1);
angles.push(data.angle);

if(vecs.length==1){
eVec = vecs[0];
}
}
angles.push(this.getMinAngle(eVec,[sVec]).angle);

var sum = 0;
for(var i=0;i<angles.length;i++){
sum+=angles[i];
}

//向量之间的夹角等于360度则表示点在矩形内
sum = sum.toPrecision(12);//取12位精度能在大部分情况下解决浮点数误差导致的精度问题
if(sum<360){
return false;
}else{
return true;
}
};

2. 计算矩形以中心缩放刚好包含矩形外一点的放大倍数

当旋转裁剪图片时需要进行适当的放大才能保证裁剪框不超出,这里就需要计算这个放大倍数。

先让裁剪图片不进行放大旋转,然后判断裁剪框四个顶点有哪些超出了裁剪图片,再然后根据超出的顶点计算放大倍数,如下:

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//计算一个矩形刚好包含矩形外一点需要的缩放倍数
SimpleCrop.prototype.getCoverPointScale = function(point,rectPoints){
var pcv = this.getPCVectorProjOnUpAndRight(point,rectPoints);

//计算矩形外一点到矩形中心向量在矩形边框向量上的投影距离
var uLen = this.vecLen(pcv.uproj);
var height = this.vecLen(pcv.up)/2;
var rLen = this.vecLen(pcv.rproj);
var width = this.vecLen(pcv.right)/2;

//根据投影距离计算缩放倍数
var scale1 = 1;
if(uLen>height){
scale1 = scale1 + (uLen - height)/height;//只要是正常矩形那么 height 和 width 不可能为0
}
var scale2 = 1;
if(rLen>width){
scale2 = scale2 + (rLen - width)/width;
}
var scale = scale2 > scale1 ? scale2 : scale1;

return scale;
};

需要的注意的是上述方法也可以用来判断点是否在矩形外,而且这种方法比计算夹角和的方法计算量要少。

3. 计算一个矩形刚好包含另外一个矩形需要进行的缩放和位移变换

具体效果如下:

在旋转裁剪图片时我们可以对其进行适当的放大从而保证裁剪框不会超出裁剪图片;但是在缩小裁剪图片却不能这么做,而应该采用移动裁剪图片的方式保证裁剪框不超出裁剪图片。

移动距离的计算可以先获得点到矩形中心的向量,然后计算该向量在矩形边框上的投影向量,最后可以用投影向量的长度减去边框长度的一半得到。

有些情况只使用位移变换并不能解决问题,比如:

此时先要把矩形放大至能完全包含虚线矩形,然后在进行位移变换。

根据裁剪框矩形图片的旋转角度虚线矩形顶点坐标的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//获取刚好包含某个矩形的新矩形
SimpleCrop.prototype.getCoveRect = function(rect,angle){
if(angle<0){
angle = 90 + angle % 90;
}else{
angle = angle % 90;
}
var rad = angle / 180 * Math.PI;

var up = {
x : rect[1].x - rect[2].x,
y : rect[1].y - rect[2].y
}
var right = {
x : rect[1].x - rect[0].x,
y : rect[1].y - rect[0].y
}
var rLen = this.vecLen(right);
var uLen = this.vecLen(up);

var nRect = [];
nRect[0] = {};
nRect[0].x = rect[0].x + rLen * Math.sin(rad) * Math.sin(rad);
nRect[0].y = rect[0].y + rLen * Math.sin(rad) * Math.cos(rad);

nRect[1] = {};
nRect[1].x = rect[1].x + uLen * Math.sin(rad) * Math.cos(rad);
nRect[1].y = rect[1].y - uLen * Math.sin(rad) * Math.sin(rad);

nRect[2] = {};
nRect[2].x = rect[2].x - rLen * Math.sin(rad) * Math.sin(rad);
nRect[2].y = rect[2].y - rLen * Math.sin(rad) * Math.cos(rad);

nRect[3] = {};
nRect[3].x = rect[3].x - uLen * Math.sin(rad) * Math.cos(rad);
nRect[3].y = rect[3].y + uLen * Math.sin(rad) * Math.sin(rad);

return nRect;
}

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
//计算图片内容刚好包含裁剪框的transform变换
SimpleCrop.prototype.getCoverTransform = function(transform,onlyTranslate){
var cRect = this.getCoveRect(this.cropPoints,this.rotateAngle);
onlyTranslate = onlyTranslate?onlyTranslate:false;

//计算放大倍数
var uScale = 1;//水平缩放倍数和垂直缩放倍数
var rScale = 1;
var cup = {
x : this.contentPoints[1].x - this.contentPoints[2].x,
y : this.contentPoints[1].y - this.contentPoints[2].y
}
var cright = {
x : this.contentPoints[1].x - this.contentPoints[0].x,
y : this.contentPoints[1].y - this.contentPoints[0].y
}
var tup = {
x : cRect[1].x - cRect[2].x,
y : cRect[1].y - cRect[2].y
}
var tright = {
x : cRect[1].x - cRect[0].x,
y : cRect[1].y - cRect[0].y
}
var uAng = this.vecAngle(cup,tup);
if(Math.abs(180 - uAng) < Math.abs(90 - uAng) || Math.abs(0 - uAng) < Math.abs(90 - uAng)){//更接近180或者0
uScale = this.vecLen(tup) / this.vecLen(cup);
rScale = this.vecLen(tright) / this.vecLen(cright);
}else{
uScale = this.vecLen(tup) / this.vecLen(cright);
rScale = this.vecLen(tright) / this.vecLen(cup);
}
uScale = uScale < 1 ? 1 : uScale;
rScale = rScale < 1 ? 1 : rScale;

var scale = uScale > rScale ? uScale : rScale;

if(onlyTranslate && scale > 1){
return transform;
}

//复制坐标
var scalePoints = [];
for(var i=0;i<this.contentPoints.length;i++){
scalePoints.push({
x : this.contentPoints[i].x,
y : this.contentPoints[i].y
});
}

//计算放大后的新坐标
if(scale>1){
transform += 'scale('+scale+')';
this._rotateScale = this._rotateScale * scale;
scalePoints = this.getTransformPoints('scaleY(-1)'+transform,this.initContentPoints);
}

//位移变换
var scaleNum = this.scaleTimes / this.times * this._rotateScale;
var count = 0;
var self = this;
var outDetails = [];
do{
//找出裁剪框超出的顶点
outDetails = this.getOutDetails(this.cropPoints,scalePoints);
if(outDetails.length>0){
count++;
outDetails.sort(function(a,b){//找出距离最远的点
var aLen = self.vecLen(a.iv);
var bLen = self.vecLen(b.iv);
if (aLen < bLen ) {
return 1;
}
if (aLen > bLen ) {
return -1;
}
return 0;
});

//开始移动
var maxFarOut = outDetails[0];
var maxFarPcv = maxFarOut.pcv;

//计算X轴位移
var uAng = this.vecAngle(maxFarPcv.up,maxFarPcv.uproj);
var uLen = this.vecLen(maxFarPcv.uproj);
var moveY = 0;

//if(uAng == 0){ //同方向
if(Math.abs(uAng)<90){//浮点数精度问题,接近0时小于90 ,接近180时大于90
moveY = - uLen * maxFarOut.uOver;
}else{
moveY = uLen * maxFarOut.uOver;
}
if(moveY!=0){
transform += ' translateY('+moveY/scaleNum+'px)';
}

//计算Y轴位移
var rAng = this.vecAngle(maxFarPcv.right,maxFarPcv.rproj);
var rLen = this.vecLen(maxFarPcv.rproj);
var moveX = 0;

if(Math.abs(rAng)<90){//同方向
moveX = rLen * maxFarOut.rOver;
}else{
moveX = - rLen * maxFarOut.rOver;
}
if(moveX!=0){
transform += ' translateX('+moveX/scaleNum+'px)';
}

//计算位移后的新坐标
if(moveX!=0 || moveY!=0){
for(var i=0;i<scalePoints.length;i++){
scalePoints[i].x = scalePoints[i].x + maxFarOut.iv.x,
scalePoints[i].y = scalePoints[i].y + maxFarOut.iv.y;
}
}
}
}while(count<2 && outDetails.length>0)

return transform;
}

先根据虚线矩形计算放大倍数,然后计算内容图片放大后的新坐标,再根据新坐标进行位移变换即可。

4. 裁剪图片

获取最终的裁剪图片需要用到CanvasdrawImage方法,但是其参数支持有限;

1
2
3
void ctx.drawImage(image, dx, dy);
void ctx.drawImage(image, dx, dy, dWidth, dHeight);
void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

因此裁剪图片时需要先把图中xy坐标系转换为x1y1坐标系,获得topleftwidthheight参数后,再执行drawImage方法。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//获取裁剪图片
SimpleCrop.prototype.getCropImage = function () {
//复制顶点坐标
var points1 = [];
for(var i=0;i<this.cropPoints.length;i++){
points1.push({
x : this.cropPoints[i].x,
y : this.cropPoints[i].y
});
}
var points2 = [];
for(var i=0;i<this.contentPoints.length;i++){
points2.push({
x : this.contentPoints[i].x,
y : this.contentPoints[i].y
});
}

//计算原点
var origin = {
x : points2[0].x,
y : points2[0].y
};
for(var i=0;i<points2.length;i++){
if(points2[i].x < origin.x){
origin.x = points2[i].x;
}
if(points2[i].y > origin.y){
origin.y = points2[i].y;
}
}

//转换坐标系
var scaleNum = this.scaleTimes / this.times * this._rotateScale;//把坐标系乘以缩放倍数,转换为实际坐标系
for(var i=0;i<points2.length;i++){
points2[i].x = Math.abs(points2[i].x - origin.x) / scaleNum;
points2[i].y = Math.abs(points2[i].y - origin.y) / scaleNum;
}
for(var i=0;i<points1.length;i++){
points1[i].x = Math.abs(points1[i].x - origin.x) / scaleNum;
points1[i].y = Math.abs(points1[i].y - origin.y) / scaleNum;
}

//计算图片旋转之前的位置(可以根据宽高校验转换是否有异常)
var center = this.getPointsCenter(points2);
var borderTop = {
x : points2[1].x - points2[0].x,
y : points2[1].y - points2[0].y
};
var width = this.vecLen(borderTop);
var borderRight = {
x : points2[2].x - points2[1].x,
y : points2[2].y - points2[1].y,
};
var height = this.vecLen(borderRight);
var imageInitRect = {
left : center.x - width/2,
top : center.y - height/2,
width : width,
height : height
};

//绘制图片
var imageRect = {
left : 0,
top : 0,
width : 0,
height : 0
};
for(var i=0;i<points2.length;i++){
if(points2[i].x > imageRect.width){
imageRect.width = points2[i].x;
}
if(points2[i].y > imageRect.height){
imageRect.height = points2[i].y;
}
}
var $imageCanvas = document.createElement('canvas');
$imageCanvas.width = imageRect.width;
$imageCanvas.height = imageRect.height;
var imageCtx = $imageCanvas.getContext('2d');
imageCtx._setTransformOrigin(center.x,center.y);//中心点
imageCtx._rotate(this.rotateAngle);
imageCtx.drawImage(this.$image,imageInitRect.left,imageInitRect.top,imageInitRect.width,imageInitRect.height);

//计算裁剪位置并截图
var _cropRect = {
left : points1[0].x,
top : points1[0].y,
width : points1[1].x - points1[0].x,
height : points1[3].y - points1[0].y
};
var $cropCanvas = document.createElement('canvas');
$cropCanvas.width = _cropRect.width;
$cropCanvas.height = _cropRect.height;
var cropCtx = $cropCanvas.getContext('2d');
cropCtx.drawImage($imageCanvas,_cropRect.left,_cropRect.top,_cropRect.width,_cropRect.height,0,0,_cropRect.width,_cropRect.height);

//缩放成最终大小
this.$resultCanvas = document.createElement('canvas');
this.$resultCanvas.width = this.size.width;
this.$resultCanvas.height = this.size.height;
var resultCtx = this.$resultCanvas.getContext('2d');
resultCtx.drawImage($cropCanvas,0,0,this.size.width,this.size.height);
};

结语

最后上述所有内容都基于需要实时获取裁剪图片进行 CSS3 Transform 变换后的新坐标,有兴趣可以查看CSS3 2D Transform Matrix