最近项目中有一个上传封面图片的功能,这个组件主要需要实现图片的预览,编辑(缩放,拖动)以及上传的功能(图片剪裁在后台实现,前端上传图片过程中会上传完整图片同时上传切割图片的尺寸位置数据等),这里我们主要分析一下组件各个功能实现过程。
业务需求
实现一个如下图所示的图片上传组件,左图为第一次上传封面,右图为当前已经包含封面,可以上传新的封面替换当前封面图片。


组件实现
1. 组件基本结构
<div class="c_bg_part">
<div class="create_radio__up_label" onClick={this.showDialog.bind(this)}>
<i class="icon_add"></i>
<span class="create_radio__msg">添加封面</span>
<input class="create_radio__up_img" />
</div>
<div class="create_radio__img_box" style={{display: this.state.radioCover || this.props.imageUrl?"block":"none"}} onClick={this.showDialog.bind(this)}>
<span>
<img src={common.fixUrl(this.state.radioCover || this.props.imageUrl)} alt="封面图" />
</span>
<span class="create_radio__change_img">上传封面</span>
</div>
</div>
点击添加封面或上传图片时,执行 this.showDialog 方法,会弹出选择图片弹窗浮层,后续一切操作均在弹窗浮层中实现。
2.弹窗浮层页面基本结构
<div class="mod_popup popup_upload_cover" id="popup_upload_cover" data-aria="popup">
<div class="popup__mask"></div>
<div class="popup__box">
<div class="popup__hd">
<h2 class="popup__tit">上传封面</h2>
<a href="javascript:;" class="popup__close" onClick={this.closedialog.bind(this)} title="关闭"><i class="popup__icon_close sprite"></i><i class="icon_txt">关闭</i></a>
</div>
<div class="popup__bd">
{/* 选择图片展示 */}
<div id="image_select" class="upload_area" style={{display:this.state.showEdit?"none":"block"}}>
<button class="upload_area__btn" onClick={this.selectImg.bind(this)} for="upload">选择本地图片</button>
<input type="file" accept=".jpg, .png" ref={this.imageInput} onChange={this.imagePreview.bind(this)} style={{display:"none"}}/>
<div class="upload_area__tips">
<p>支持JPG/PNG,小于5MB</p>
<p>图片尺寸大于500*500</p>
<p>清晰的图片更容易被推荐</p>
</div>
</div>
{/* 编辑图片展示 */}
<div id="image_edit" style={{display:this.state.showEdit?"block":"none"}}>
<div class="upload_demo" id="uploaded_img">
<img id="cover_preview" src="" alt="" />
<div class="upload_demo__window" id="js_upload_demo__window"></div>
</div>,
<div class="mod_form__range">
<input class="from__range__slider" type="range" ref={this.sliderBar} onChange={this.handleSliderBar.bind(this)} min="0" max="100" />
<button class="from__range__minus" onClick={this.minimizeImg.bind(this)}>-</button>
<button class="from__range__add" onClick={this.maximizeImg.bind(this)}>+</button>
{/* <!--需要动态改变form__range__value 的值 --> */}
<i class="from__range__value" style={{width:`${this.state.percent}%`}}></i>
</div>
<div class="upload_btns">
<button class="mod_btn btn_publish c_btn_normal" onClick={this.reSelectImg.bind(this)}>重选图片</button>
<button class="mod_btn btn_publish c_btn_highlight" onClick={this.submitPoster.bind(this)}>确定</button>
</div>
</div>
<div class="upload_law" ><a title="法律声明" target="_blank" href="#">法律声明</a></div>
</div>
</div>
</div>

3. 图片预览效果实现
点击选择图片按钮,button 的 for 属性指向 input 标签,直接进入图片选择,选中需要上传的图片,触发 input 标签 onChange 方法,在这个方法里我们主要实现图片的本地预览,具体实现如下:
imagePreview() {
let imageFile = this.imageInput.current.files[0];
if(imageFile) {
// 限制图片大小
if(imageFile.size > 1024*1024*5) {
tipUtil.tip('请选择小于5M的图片!');
// 选择图片大小超过 5MB 及时更改 input 标签 files 内容,避免两次选择同一超过 5MB 图片时
// 无法触发 onChange 事件导致只有第一次选择图片会有超出限制的提示
let list = new DataTransfer();
let file = new File(["content"], "neverused.txt");
list.items.add(file);
this.imageInput.current.files = list.files;
this.setState({
showEdit: false,
percent: 0
});
return;
}
this.setState({showEdit:true});
this.imageFile = imageFile;
let reader = new FileReader();
// 图片上层的蒙版,用于确定上传图片的剪裁部分(尺寸为 266 * 266)
let picCoverRect = document.getElementById("js_upload_demo__window");
picCoverRect.style.width = 266 +'px';
picCoverRect.style.height = 266 +'px';
let originalWidth = picCoverRect.style.width;
let originalHeight = picCoverRect.style.height;
let picturePreview = document.getElementById("cover_preview");
reader.readAsDataURL(imageFile);
reader.onload = (e) => {
picturePreview.src = e.target.result;
let image = new Image();
image.onload = (e) => {
// 确定当前选择图片的尺寸
this.imageWidth = image.width;
this.imageHeight = image.height;
if(this.imageWidth < 500 || this.imageHeight < 500) {
this.setState({showEdit: false});
tipUtil.tip('支持图片尺寸大于500*500');
return;
}
// 更改缩放进度条参数
this.sliderBar.current.value = 0;
// 判断图片类型(宽图片或者高图片),如果为宽图片,则以图片的高度计算图片压缩比例,
// 即将图片高度限制在 picCoverRect 中,此时图片只能进行左右拖动;如果为高图片,
// 则以图片的宽度计算图片压缩比例,即将图片宽度限制在 picCoverRect 中,此时图片
// 只能进行上下拖动。
if(this.imageWidth > this.imageHeight) {
this.isWide = true;
// 计算所选图片最大压缩比例
this.zoomLevel = this.imageHeight/parseInt(originalHeight);
this.rate = this.zoomLevel;
// 设置图片位置
this.setPosition(0);
} else {
this.zoomLevel = this.imageWidth/parseInt(originalWidth);
this.rate = this.zoomLevel;
this.setPosition(0);
}
}
image.src = e.target.result;
}
}
}
接下来,我们实现 this.setPosition 方法,用于处理每次缩放后图片图片位置问题。
// 用于每次从新设定图片位置尺寸等,传入参数为当前图片缩放百分比(按照图片缩放进度条传入的 value)
// 说明:项目中图片预览区域的样式设置:默认情况下图片左上角处于剪裁框中心点,因而针对 marginTop 与 marginLeft 的设置需要做针对性修改
setPosition(percent) {
let picturePreview = document.getElementById("cover_preview");
// 根据传入的图片当前缩放百分比,计算图片在当前缩放比例下应该显示的尺寸与最大压缩比例下
// 的显示尺寸的比值;例如 percent == 0 时,表示图片未进行任何放大操作,则 tempParam = 1;
// 当 percent == 100 时,表示图片已经放大为图片原始尺寸大小,则 tempParam = this.zoomLevel
let tempParam = ((this.zoomLevel-1)/100)*percent + 1;
if(this.isWide) {
let showWidth = this.imageWidth/this.imageHeight * this.originalHeight;
// 设置图片的宽高尺寸
picturePreview.style.width = showWidth*tempParam + 'px';
picturePreview.style.height = this.originalHeight*tempParam + 'px';
// 设置 margin-top 与 margin-left,使得每次显示在裁剪框内的区域始终为图片中心区域
picturePreview.style.marginTop = (-1*(this.originalHeight*tempParam)/2+133) + 'px';
picturePreview.style.marginLeft = (-1*(showWidth*tempParam)/2+133) +'px';
} else {
let showHeight = this.imageHeight/this.imageWidth * parseInt(this.originalWidth);
picturePreview.style.width = this.originalWidth*tempParam + 'px';
picturePreview.style.height = showHeight*tempParam + 'px';
picturePreview.style.marginTop = (-1*(showHeight*tempParam)/2+133) + 'px';
picturePreview.style.marginLeft = (-1*(this.originalWidth*tempParam)/2+133) + 'px';
}
}
4. 图片缩放效果实现
对于图片的缩放,主要通过一个可以拖动的进度条进行操作,或者直接点击进度条两侧的 - + 按钮。对于进度条的实现,可以直接利用原生的 input 标签实现,设置 type = “range” 即可,每次拖动都会触发 input 标签的 onChange 事件,我们可以就可以通过当前 input 标签的 value 值去重新设置图片尺寸与位置。具体实现如下:
handleSliderBar() {
let percent = parseInt(this.sliderBar.current.value);
this.setState({percent:percent});
this.setPosition(percent);
}
同理可以实现 - + 的缩放功能,代码如下:
// 这里我们设定每次缩放比例变化为 10%
minimizeImg() {
if(this.sliderBar.current.value < 10) {
this.sliderBar.current.value = 0;
this.setState({percent: 0});
this.setPosition(0);
} else {
let temp = this.sliderBar.current.value - 10;
this.sliderBar.current.value = temp;
this.setState({percent: temp});
this.setPosition(temp);
}
}
maximizeImg() {
if(this.sliderBar.current.value > 90) {
this.sliderBar.current.value = 100;
this.setState({percent: 100});
this.setPosition(100);
} else {
let temp = this.sliderBar.current.value + 10;
this.sliderBar.current.value = temp;
this.setState({percent: temp});
this.setPosition(temp);
}
}
5. 图片拖动实现
对于图片的拖动,我们可以考虑将拖动的处理逻辑放到 componentDidMount 中实现,借助于鼠标事件中的 mousedown,mousemove 与 mouseup 事件完成。具体步骤就是:监听图片的 mousedown 事件,记录事件触发时鼠标在当前页面的位置 x, y 以及当前图片的 margin-top 与 margin-left,用于后续计算图片移动位置,继续在 mousemove 事件中记录鼠标移动过程中的位置 tx, ty,从而根据鼠标移动位置计算图片的最新位置。具体实现如下:
let picturePreview = document.getElementById("cover_preview");
picturePreview.onmousedown = (event) => {
let e = event || window.event;
e.preventDefault();
let dragging = true;
let x = e.pageX;
let y = e.pageY;
let pLeft = parseInt(picturePreview.style.marginLeft);
let pTop = parseInt(picturePreview.style.marginTop);
document.onmousemove = (event) => {
if(dragging) {
let ev = event || window.event;
ev.preventDefault();
let tx = ev.pageX;
let ty = ev.pageY;
let moveX = pLeft+(tx-x);
let moveY = pTop+(ty-y);
let width = parseInt(picturePreview.style.width);
let height = parseInt(picturePreview.style.height);
// 限制图片不能被拖出蒙版区域
if(moveX<(width-this.originalWidth/2)*-1) {
moveX = (width-this.originalWidth/2)*-1;
}
if(moveX>(this.originalWidth/2)*-1) {
moveX = (this.originalWidth/2)*-1;
}
if(moveY<(height-this.originalHeight/2)*-1) {
moveY = (height-this.originalHeight/2)*-1;
}
if(moveY>(this.originalHeight/2)*-1) {
moveY = (this.originalHeight/2)*-1;
}
picturePreview.style.marginLeft = moveX + "px";
picturePreview.style.marginTop = moveY + "px";
}
}
document.onmouseup = () => {
dragging = false;
document.οnmοusemοve = null;
document.οnmοuseup = null;
}
}
6. 计算图片被切割区域
最后一个步骤就是计算当前蒙版区域内限制图片区域的真实位置与尺寸,话不多说,直接看代码:
getCropInfo() {
// x, y 分别表示图片需要剪裁区域左上角的横纵坐标,height, width 分别表示需要剪裁的真实宽高
let opts = {
x : 0,
y : 0,
width : 0,
height : 0
};
let picturePreview = document.getElementById("cover_preview");
let width = parseInt(picturePreview.style.width);
let height = parseInt(picturePreview.style.height);
if(this.isWide) {
this.rate = this.imageWidth/width;
} else {
this.rate = this.imageHeight/height;
}
let marginTop = parseInt(picturePreview.style.marginTop);
let marginLeft = parseInt(picturePreview.style.marginLeft);
opts.x = Math.floor(this.rate*(Math.abs(marginLeft)));
opts.y = Math.floor(this.rate*(Math.abs(marginTop)));
// 266 为图片剪裁视窗的宽高
opts.width = Math.floor(this.rate*266);
opts.height = Math.floor(this.rate*266);
// 裁剪尺寸大于图片原始尺寸需要特殊处理
if(this.imageWidth < opts.width) {
opts.height = this.imageWidth*opts.height/opts.width;
opts.width = this.imageWidth;
}
if(this.imageHeight < opts.height) {
opts.width = this.imageHeight*opts.width/opts.height;
opts.height = this.imageHeight;
}
return opts;
}
7. 文件上传
通过对上一步骤文件剪裁区域的计算结果,我们已经确定到所需剪裁图片区域在原图中的位置(剪裁区域左上角的x,y坐标以及剪裁区域的宽高),最后只要将剪裁尺寸及其原始图片等信息进行提交即可。