在我的上一篇文章已经介绍过如何实现图片压缩,本篇文章主要讲解在此基础上单独实现的图片裁剪功能.
点击选择文件上传一张图片,点击裁切时会出现裁剪框,移动或拉伸裁剪框会在下方生成裁剪后的图片.点击下拉框选择裁剪图的压缩比例.最终点击生成就可以将裁剪压缩后的图片下载到本地.
githup完整代码地址
对应页面图
用户点击选择文件后将图片转换成base64编码在页面上预览,裁剪功能的核心就是实现中间那块虚线的裁剪框.
以上两个功能是实现裁剪的重点,最终的目的是通过裁剪获取比例数据就算完成任务,即裁剪框距离左边界的left,距离上边界的top,还有裁剪框自身的width和height.裁剪的使命就是为了获取left,top,width和height这四条数据,随后我们再通过其他的手段通过这四条数据生成裁剪后的图片.
先来实现裁剪框的第一个核心功能,鼠标在框上按下后可以拖动框,按键松起后就不能再拖动.
首先定义一个构造函数cropImg,用它生成的对象实现裁剪.目前只需要关注构造函数里的this.target,它是一个div元素,和图片一样大小,图片以背景图的形式完全充满了该div,正是上图中图片外延的虚线框.this.target相当于原始图片.为了便于叙述,我们就将this.target称为图片框.
function cropImg(options) {
this.target = document.getElementById(options.target) || document;
this.width = 0;
this.height = 0;
this.container_width = 0;
this.container_height = 0;
this.mouse_index = 0;
this.callback = options.callback || function () {};
this.fun_list = []; //所有绑定的事件存储起来,插件销毁时解绑
this.init();
}
在this.init()函数里会执行下面渲染裁剪框的逻辑.
在做移动功能之前需要先把裁剪框在页面上渲染出来,否则框都没有就谈不上如何移动.裁剪框也是一个div,将它渲染到图片框的内部.
下面的代码就是为了做上面三件事情,创建一个div元素container作为裁剪框对象,通过算出图片框的宽高从而得到裁剪框的宽高,并设置好它的left和top让它处于图片框的正中央位置.
cropImg.prototype.renderBox = function () {
const width = (this.container_width = parseInt( //算出图片框的宽
getComputedStyle(this.target).width
));
const height = (this.container_height = parseInt( //算出图片框的高
getComputedStyle(this.target).height
));
this.radio = width / height; //算出宽高比例
this.width = parseInt(width / 3); //得到裁剪框的宽
this.height = parseInt(height / 3); //得到裁剪框的高
const container = document.createElement('DIV'); //创建裁剪框
container.style.width = `${this.width}px`;
container.style.height = `${this.height}px`;
container.style.left = `${this.container_width/2 - this.width/2}px`; //初次加载裁剪框放到正中间的位置
container.style.top = `${this.container_height/2 - this.height/2}px`;
};
现在只需要把裁剪框container塞到图片框this.target里面就可以在页面看到它了.在此之前,现将裁剪框的移动功能做好.如何去开发移动功能?无非是对裁剪框这个div做事件绑定.
实现移动功能的关键便是上述三个鼠标事件mousedown,mousemove和mouseup的编写.在当前的项目中不仅裁剪框需要绑定这三个事件,裁剪框右下角的小方块也要绑定鼠标事件,以对裁剪框进行拉伸,如下图:
既然拉伸和移动都需要绑定鼠标事件,我们可以将绑定事件的逻辑单独抽离出来供其他函数来调用.现在需要对裁剪框绑定mousedown,mousemove和mouseup事件,那该如何调用呢?
this.bindMouseEvent({
mousedown: {
element: container,//container是裁剪框的dom对象
},
mousemove: {
element: container,
callback(e, start_x, start_y) {
console.log("每次触发移动事件后的回调函数");
},
},
});
bindMouseEvent函数里面需要传递一个参数对象,key为具体要绑定的事件名,element是需要绑定事件的dom元素,callback是每次触发完该事件后的回调函数.
如上所述,裁剪框准备绑定两个事件mousedown和mousemove,并且mousemove还定义了一个回调函数callback.那bindMouseEvent的逻辑又是如何编写的呢?
下面这段代码便是实现裁剪功能的核心了.mouseUpHandler等下再看,直接关注for循环里面实现的逻辑.params就是上面传递过来的参数对象 {mousedown: {element: container},mousemove: {element: container,callback() {}}}.直接对params作for in循环,获取每个对象的element和callback,目的就是对element调用addEventListener(key,fn),key就是事件类型,这个容易得到.而fn是具体需要绑定的函数,fn是通过调用this.strategyEvent函数获取.
/**
* 对dom元素绑定鼠标点击弹起和移动事件
*/
cropImg.prototype.bindMouseEvent = function (params) {
this.mouseUpHandler(); //处理mouseup事件
this.mouse_index++; //每当需要绑定一次鼠标事件,mouse_index自增1,作为唯一的id标识
for (let key in params) {
const value = params[key]; // 得到key和value
let { element, callback } = value;
const defaultFn = this.strategyEvent(key, this.mouse_index); //获取默认运行函数
if (!defaultFn) {
//如果发现params的参数配置里面的key没有和在策略函数里定义的默认函数匹配上,那么判定当前对应的key-value是无效的
continue;
}
element = Array.isArray(element) ? element : [element]; //不是数组也转化成数组
element.forEach((ele) => {
const fn = (e) => {
//开始绑定事件
defaultFn.call(this, e, callback); //某些默认策略函数需要callback参数,所以params.callback也作为参数传入
};
ele.addEventListener(key, fn);
});
}
};
为什么事件绑定函数fn要通过this.strategyEvent函数获取呢?不这样做也可以,那就需要写很多的if else代码,如下所示.这样写起来的代码不优雅,而我们上面通过调用this.strategyEvent根据key可以直接获取要绑定的函数defaultFn.this.strategyEvent就相当于一个函数工厂,你传给它一个key它就返回你一个处理函数.这里采用了策略模式优化了if else结构.
if(key === "mouseup"){
ele.addEventListener(key, function(){
});
}else if(key === "mousemove"){
ele.addEventListener(key, function(){
});
}else if(key === "mousedown"){
ele.addEventListener(key, function(){
});
}
bindMouseEvent做的事情很纯粹,它就是通过调用addEventListener给不同的element元素绑定不同的事件函数,而事件函数具体内容在哪里呢?this.strategyEvent里面定义不同事件函数的逻辑.
下面strategyEvent函数中中key是传入的事件类型,比如"mousedown"或者"mousemove".idx是全局唯一的id标识,每调用一次bindMouseEvent函数idx就自增1.按照前面所述目前裁剪框和拉伸块都需要绑定鼠标事件,那么裁剪框绑定事件时对应的idx就等于1,拉伸块对应的idx是2.
/**
* 定义一些策略函数
*/
cropImg.prototype.strategyEvent = function (key, idx) {
function mousedown(e) {
//鼠标按下时的默认操作
e.stopPropagation();
this[`mouse_${idx}`] = true; //检测鼠标是否处于按下的状态
this[`start_x${idx}`] = e.pageX;
this[`start_y${idx}`] = e.pageY;
}
function mousemove(e, callback) {
//鼠标移动时的默认操作
e.stopPropagation();
e.preventDefault();
if (!this[`mouse_${idx}`]) {
return false;
}
if (this[`timer${idx}`]) {
return false;
}
this[`timer${idx}`] = setTimeout(() => {
callback.call(this, e, this[`start_x${idx}`], this[`start_y${idx}`]);
this[`start_x${idx}`] = e.pageX;
this[`start_y${idx}`] = e.pageY;
clearTimeout(this[`timer${idx}`]);
this[`timer${idx}`] = null;
}, 20);
}
const funList = { mousedown, mousemove };
return funList[key];
};
每次触发mousemove函数后都会调用callback函数,calback函数里接收到了事件对象e以及初始坐标位置start_x和start_y.事件对象e可以获取到当前的鼠标位置e.pageX和e.pageY.让e.pageX-start_x就能得到鼠标横向移动的距离,e.pageY-start_y得到鼠标纵向移动的距离.既然能得到鼠标移动的距离,我们就可以在callback里面让裁剪框动起来.
在调用的时候将callback的逻辑补齐,每一次裁剪框的mousemove事件触发后就会调用下面的callback函数.x是鼠标横向移动的距离,y是鼠标纵向移动的距离.x和y加上裁剪框的left和top并重新赋给裁剪框,如此裁剪框的位置便随着鼠标移动而滑动了.
this.bindMouseEvent({
mousedown: {
element: container,
},
mousemove: {
element: container,
callback(e, start_x, start_y) {
const x = e.pageX - start_x;
const y = e.pageY - start_y;
let top = parseInt(getComputedStyle(container).top);
let left = parseInt(getComputedStyle(container).left);
top += y;
left += x;
container.style.top = `${top}px`;
container.style.left = `${left}px`;
},
},
});
上面只编写了mousemove和mousedown事件,而mouseup事件另外单独处理了,它的逻辑很简单,全局监听鼠标弹起,一旦触发就改变鼠标的状态。
/**
* mouseup处理函数
*/
cropImg.prototype.mouseUpHandler= function(){
if (this.mouse_index > 0) { //已经绑定过mouseup事件了,mouseup事件绑定一次即可
return false;
}
document.addEventListener('mouseup', ()=>{
Array.from(Array(this.mouse_index)).forEach((value, idx) => {
this[`mouse_${idx + 1}`] = false;
});
});
}
上图中红线标注的小方块点击拉伸后可以改变裁剪框的大小.它的实现思路仍然是先渲染小方块,随后对小方块绑定mousedown事件,但不要对小方块绑定mousemove事件,因为小方块面积太小了,鼠标很容易滑出影响拉伸效果.所以最好同时对裁剪框和图片框绑定mousemove事件,函数触发时等比例拉伸裁剪框.
代码如下.创建一个div元素symbol作为小方块的dom对象,随后对该dom对象做鼠标事件绑定
/**
* 渲染右下角的拉升框
*/
cropImg.prototype.renderSymbol = function () {
const symbol = document.createElement('DIV');
symbol.setAttribute('class', 'symbol');
this.bindMouseEvent({
mousedown: {
element: symbol,
},
mousemove: {
element: [this.target, this.mask],
callback(e, start_x) {
const x = e.pageX - start_x;
const width = parseInt(getComputedStyle(this.mask).width) + x;
const height = parseInt((width * 1) / this.radio);
this.mask.style.width = `${width}px`;
this.mask.style.height = `${height}px`;
this.width = width;
this.height = height;
},
},
});
this.symbol = symbol;
this.mask.appendChild(symbol);
};
现在在外部调用刚才开发好的裁剪插件cropImg,"box"是图片框的id,也就是插件内定义的this.target.我们期待回调函数callback能返回当前裁剪框的比例数据,即裁剪框的宽高以及距离左边界的left和距离上边界的top.而container_height和container_width是图片框的宽高.
new cropImg({
target: 'box',
callback({left,top,width,height,container_height,container_width,}) {
},
});
在构造函数中加入this.callback属性供外部调用
function cropImg(options) {
this.target = document.getElementById(options.target) || document;
this.width = 0;
this.height = 0;
this.container_width = 0;
this.container_height = 0;
this.mouse_index = 0;
this.callback = options.callback || function () {};
this.init();
}
目前插件还没有返回比例数据的功能,我们可以把this.callback放到mouseup当中去执行.每次鼠标一弹出,就执行this.callback函数并返回相关的比例数据.
/**
* mouseup处理函数
*/
cropImg.prototype.mouseUpHandler= function(){
if (this.mouse_index > 0) { //已经绑定过mouseup事件了
return false;
}
document.addEventListener('mouseup', ()=>{
Array.from(Array(this.mouse_index)).forEach((value, idx) => {
this[`mouse_${idx + 1}`] = false;
});
const { top, left } = this.mask.style; //this.mask是裁剪框的dom对象
this.callback({ //将相关比例数据返回
width: this.width, //裁剪框的宽
height: this.height, //裁剪框的高
top: parseInt(top),
left: parseInt(left),
container_height: this.container_height,//图片框的高
container_width: this.container_width, //图片框的宽
});
});
}
现在鼠标每次拖拽或者拉伸完就会执行callback函数,并给出了此时裁剪框的比例数据.接下来我们要根据这些比例数据生成裁剪后的图片.
new cropImg({
target: 'box',
callback({left,top,width,height,container_height,container_width,}) {
const canvas_bak = document.createElement('CANVAS');
const ctx_bak = canvas_bak.getContext('2d');
canvas_bak.width = container_width;
canvas_bak.height = container_height;
ctx_bak.drawImage(img, 0, 0, container_width, container_height);
const canvas = document.createElement('CANVAS');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(canvas_bak,left,top,width,height,
0,0,width,height
);
const value = Number(document.getElementById('sel').value);
const code = canvas.toDataURL('image/jpeg', value);
const image = new Image();
image.src = code;
image.onload = () => {
const des = document.getElementById('production');
des.innerHTML = '';
des.appendChild(image);
compress_img = image;
};
},
});
compress_img为裁切压缩后的图片对象,当用户点击生成时,generate函数会触发.它先创建一个A标签,给href属性赋值上压缩图片的base64编码,以及给download属性加一个值,该值对应的是图片下载后的名称.最后执行A标签的单击将图片下载到本地.
/**
* 下载图片
* @param {*}
*/
function generate() {
if (!compress_img) {
return false;
}
const a = document.createElement('A');
a.href = compress_img.src;
a.download = 'download';
a.click();
}