自由拼图?
自由拼图是美图秀秀中的一个功能,它可以让用户在背景图片上插入自己的图片,并可以对插入图片旋转,拖拽,缩放
。当然,如果用户对插入的图片不满意,可以用另一张图片替换
选中的图片,或者删除
选中图片。
效果
本屌之前用actionscript实现过,参见仿美图秀秀的自由拼图(注意里面基本上没代码说明).
这里用html5的canvas实现。
这个是本屌博客园去年一篇文章的效果,内容也是说的这个,不过很无耻的只贴了代码,没有任何说明。
下面的是录制的效果视频
http://v.youku.com/v_show/id_XMTM3MTgzMzIyOA==.html
布局
<div id='html5_puzzle' ms-controller='html5_puzzle' class='ms-controller'>
<div id='puzzle_model'>
<ul>
<li>
<img src='imgs/small_img/1.jpg'>
...
</li>
</ul>
</div
><div id='puzzle_canvas'>
<div id='canvas_inner'>
<canvas id="main_canvas"></canvas>
<img class='middleware_img'>
<img class='middleware_img'>
...
<div id='canvas_menu'>
<a href='javascript:void(0)' id='puzzle_delete'>删除</a>
<a href='javascript:void(0)' id='puzzle_update'>更改图片</a>
</div>
<img id="puzzle_bg" src="imgs/1.jpg" width='548' height='411'/>
<canvas id='canvas_middleware'></canvas>
</div>
</div>
<div id='puzzle_control'>
<a href="javascript:;" id="puzzle_add">
<span>添加</span>
<input type="file" multiple="" id='puzzle_add_input'>
</a
><a id='puzzle_upload'>上传</a>
</div>
</div>
#main_canvas
是主要的工作区域.middleware_img
是若干<img>
,<input>
读取从外面选中的若干图片数据,将数据以Base64编码,依次写入<img>
的src属性。这些<img>
后面将会被当作参数,传给canvas#canvas_middleware
是代理。<input>
读取的数据由#canvas_middleware
调用toDataURL()
方法编码成Base64形式#puzzle_bg
和.middleware_img
作用差不多,只不过这里是将背景图片的src传给它。它会将背景图片数据传给canvas
读取选中图片
var img_upload_instance=new img_upload({
add_btn:'puzzle_add',
onSelect:onSelect//读取图片回调
});
define("html5_imgupload",["avalon-min"], function(avalon){
var html5_img_upload=function(options){
this.init(options);
}
html5_img_upload.prototype = {
init : function(options) {
//如果有自定义属性,覆盖默认属性
avalon.mix(html5_img_upload.prototype,options);
this.init_events();
},
init_events : function() {
var _this=this;
avalon.bind($(this.add_btn),'change',function(e) {
_this.get_files(e);
});
},
file_filter:[],
ori_images:[],
add_btn:null,
upload_btn:null,
max_upload_num:9,
onSelect:function(file_filter){},
_start:0,//已经读取图片数量
filter:function(files) {
var arrFiles=[];
for (var i=0,file;file=files[i];i++){
if(this._start+i<this.max_upload_num){
if(file.type.indexOf("image")==0||(!file.type&&/\.(?:jpg|png|gif)$/.
test(file.name)))
arrFiles.push(file);
else {
alert('文件'+file.name+'不是图片');
}
}else{
alert('一次最多能上传'+this.max_upload_num+'张图片');
break;
}
}
return arrFiles;
},
get_files:function(e) {
var files=e.target.files||e.dataTransfer.files;
this.file_filter=this.file_filter.concat(this.filter(files));
this.onSelect(this.file_filter);
},
destroy_hook:function(){},
_destroy:function(){
this.file_filter=[];
$(this.add_btn).value='';
this.ori_images=[];
this._start=0;
this.destroy_hook();
},
upload:function(i,file_filter){}
};
return html5_img_upload;
});
自定义图片选取回调
<div id='puzzle_canvas'>
<div id='canvas_inner'>
<canvas id="main_canvas"></canvas>
<img ms-repeat='middleware_list' ms-attr-src='el' class='middleware_img'>
...
</div>
</div>
function onSelect(file_filter){
for(var i=this._start,len=file_filter.length;i<len;i++) {//遍历选中图片
var reader=new FileReader();
reader.onload=(function(i){//图片读取的回调
return function(e){
var dataURL=e.target.result,canvas_middleware=$('canvas_middleware'),
ctx=canvas_middleware.getContext('2d'),img=new Image();
img.onload = function() {//图片加载的回调
if(img.width>200||img.height>200){//等比例缩放
var prop=Math.min(200/img.width,200/img.height);
img.width=img.width*prop;
img.height=img.height*prop;
}
//设置中转canvas尺寸
canvas_middleware.width=img.width;
canvas_middleware.height=img.height;
ctx.drawImage(img, 0, 0, img.width, img.height);
//将读取图片转换成base64,写入.middleware_list的src
html5_puzzle.middleware_list.push(canvas_middleware.
toDataURL("image/jpeg"));
if(!file_filter[i+1]){
//图片延迟加载到canvas,因为canvas有个读取过程,但是没有回调
var t = window.setTimeout(function() {
canvas_puzzle.init();
clearTimeout(t);
}, 1000);
}
};
img.src = dataURL;
};
delete reader;
})(i);
reader.readAsDataURL(file_filter[i]);//开始读取图片
}
this._start=0;
img_upload_instance._destroy();
}
canvas初始化
var canvas = new canvasElement.Element();
canvas.init('main_canvas', {
width : canvas_w,
height : canvas_h
});
Canvas.Element.prototype.init = function(el, oConfig) {
if (el == '') {
return;
}
this._initElement(el);
this._initConfig(oConfig);
this._createCanvasBackground();
this._createContainer();
this._initEvents();
this._initCustomEvents();
};
Canvas.Element.prototype._initElement = function(el) {
this._oElement = document.getElementById(el);
this._oContextTop = this._oElement.getContext('2d');
};
Canvas.Element.prototype._initCustomEvents = function() {//设置自定义事件
this.onRotateStart = new Canvas.CustomEvent('onRotateStart');
this.onRotateMove = new Canvas.CustomEvent('onRotateMove');
this.onRotateComplete = new Canvas.CustomEvent('onRotateComplete');
this.onDragStart = new Canvas.CustomEvent('onDragStart');
this.onDragMove = new Canvas.CustomEvent('onDragMove');
this.onDragComplete = new Canvas.CustomEvent('onDragComplete');
};
Canvas.Element.prototype._initConfig = function(oConfig) {
this._oConfig = oConfig;
this._oElement.width = this._oConfig.width;
this._oElement.height = this._oConfig.height;
this._oElement.style.width = this._oConfig.width + 'px';
this._oElement.style.height = this._oConfig.height + 'px';
};
Canvas.Element.prototype._initEvents = function() {
var _this=this;
avalon.bind(this._oElement,'mousedown',function(e){
_this.onMouseDown(e);
});
avalon.bind(this._oElement,'mouseup',function(e){
_this.onMouseUp(e);
});
avalon.bind(this._oElement,'mousemove',function(e){
_this.onMouseMove(e);
});
};
Canvas.Element.prototype._createContainer = function() {
var canvasEl = document.createElement('canvas');
canvasEl.id = this._oElement.id + '-canvas-container';
...
this._oContextContainer = oContainer.getContext('2d');
};
Canvas.Element.prototype._createCanvasBackground = function() {
var canvasEl = document.createElement('canvas');
canvasEl.id = this._oElement.id + '-canvas-background';
...
this._oContextBackground = oBackground.getContext('2d');
};
可以看到初始化过程中多次创建<canvas>
main_canvas-background-canvas
绘制背景图片main_canvas-container-canvas
绘制除当前操作的图片外的其余图片main_canvas
绘制当前操作的图片
从上到下:main_canvas
->main_canvas-container-canvas
->main_canvas-background-canvas
canvas context:
main_canvas
->_oContextTopmain_canvas-container-canvas
->_oContextContainermain_canvas-background-canvas
->_oContextBackground
这下就看到canvas自由拼图的原理了,原来是3个canvas上下重叠起来,操作的时候对不同的canvas进行不同目标的绘制。
canvas绘制图片
接着图片选取完成后canvas_puzzle.init()
var canvas_img=[];
...
var canvas_puzzle= function() {
return {
init : function() {
var img_list=document.querySelectorAll('.middleware_img');
//第一张作为背景图片
canvas_img[0]=new canvasImg.Img($('puzzle_bg'), {});
avalon.each(img_list,function(i,el){
canvas_img.push(new canvasImg.Img(el, {}));
canvas.addImage(canvas_img[i+1]);
});
canvas.setCanvasBackground(canvas_img[0]);
...
}
};
}();
canvasImg.Img
是canvas对图片的封装.第一个参数是<img>
,前面提到的#puzzle_bg和.middleware_img就是作为第一个参数传入canvasImg.Img
.第二个参数用来自定义图片的一些属性,比如边框宽度,4个角的大小等,如果定义的话会覆盖默认值。canvasImg.Img
封装图片后的效果canvas.addImage(canvas_img[i+1])
将canvas对除背景图片外的图片的封装
绘制到canvas上
Canvas.Element.prototype.addImage = function(oImg) {
if(isEmptyObject(this._aImages))
this._aImages = [];
this._aImages.push(oImg);
this.renderAll(false,true);
};
_aImages
是保存canvas图片封装的数组,renderAll()
方法很重要,后面会说到。
canvas.setCanvasBackground(canvas_img[0])
将背景图片绘制到canvas
Canvas.Element.prototype.setCanvasBackground = function(oImg) {
this._backgroundImg = oImg;
var originalImgSize = oImg.getOriginalSize();
this._oContextBackground.drawImage(oImg._oElement, 0, 0, originalImgSize.width,
originalImgSize.height);
};
Canvas.Img.prototype.getOriginalSize = function() {//获得canvas尺寸
return { width: this._oElement.width, height: this._oElement.height }
};
canvas事件操作
mousedown
Canvas.Element.prototype.onMouseDown = function(e) {
$('canvas_menu').style.display="none";
var mp = this.findMousePosition(e);//鼠标相对位置
if (this._currentTransform != null || this._aImages == null) {
return;
}
var oImg = this.findTargetImage(mp, false);//获取目标图片
//事件触发位置是不是在4个角上
var action = (!this.findTargetCorner(mp, oImg)) ? 'drag' : 'rotate';
if (action == "rotate") {
this.onRotateMove.fire(e);//触发自定义事件
} else if (action == "drag") {
this.onDragMove.fire(e);
}
this._prevTransform=this._currentTransform = {
target: oImg,
action: action,
scalex: oImg.scalex,
offsetX: mp.ex - oImg.left,
offsetY: mp.ey - oImg.top,
ex: mp.ex, ey: mp.ey,
left: oImg.left, top: oImg.top,
theta: oImg.theta
};
//设置菜单位置
$('canvas_menu').style.transform='rotate('+oImg.theta*180/3.14+'deg)';
$('canvas_menu').style.left=oImg.left+"px";
$('canvas_menu').style.top=oImg.top+"px";
this.renderAll(false,false);
};
this._prevTransform
保存当前目标图片状态,替换图片时会用到这个变量renderAll()
方法是整个绘制的核心方法
Canvas.Element.prototype.renderAll=function(allOnTop,allowCorners) {
var containerCanvas=allOnTop?this._oContextTop:this._oContextContainer;
this._oContextTop.clearRect(0,0,parseInt(this._oConfig.width),parseInt(this._oConfig.height));
containerCanvas.clearRect(0,0,parseInt(this._oConfig.width),parseInt(this._oConfig.height));
if(allOnTop){//所有图片都要在最上面
var originalImgSize=this._backgroundImg.getOriginalSize();
//在最上层canvas绘制背景图片
this._oContextTop.drawImage(this._backgroundImg._oElement, 0, 0,
originalImgSize.width,originalImgSize.height);
}
for(var i=0,l=this._aImages.length-1;i<l;i+=1){
this.drawImageElement(containerCanvas,this._aImages[i],allowCorners);
}
var last_aImages=this._aImages[this._aImages.length-1];
this.drawImageElement(this._oContextTop,last_aImages ,allowCorners);
};
可以看到,如果allOnTop=false
,从_aImages
封装图片的数组的第一个元素到倒数第二个元素,会绘制到中间一层container-canvas
,而_aImages
数组的最后一个元素,即当前操作的图片,会绘制到最上面一层top-canvas
,当然如果改变操作对象,_aImages
数组也会相应的变化,保证当前操作的图片在_aImages
数组的最后一个位置。
如果allOnTop=true
,_aImages
数组中的所有图片还有背景图片都会被绘制到最上面一层top-canvas
.
设置allOnTop参数
的目的在于上传时只有所有图片都在一个canvas context上,调用toDataURL()
方法,就能获得整个拼图的base64字符串。
第二个参数allowCorners
表示绘制时是否添加边框,边角。
前面将选中图片及背景图片绘制到canvas,最后this.renderAll(false,true)让边框,边角可见,是为了让用户知道图片可以进行操作。
Canvas.Element.prototype.addImage = function(oImg) {
...
this.renderAll(false,true);
};
另外,无论怎么设置,renderAll()
方法最终都会调用drawImageElement()
方法进行实际意义上的绘制
Canvas.Element.prototype.drawImageElement = function(context,oImg,allowCorners) {
if(oImg){
oImg.cornervisibility=allowCorners;
var offsetY = oImg.height / 2;
var offsetX = oImg.width / 2;
context.save();
context.translate(oImg.left, oImg.top);
context.rotate(oImg.theta);
context.scale(oImg.scalex, oImg.scaley);
this.drawBorder(context, oImg, offsetX, offsetY);
var originalImgSize = oImg.getOriginalSize();
var polaroidHeight =((oImg.height-originalImgSize.height)-(oImg.width-originalImgSize.width))/2;
context.drawImage(oImg._oElement,-originalImgSize.width/2,(-originalImgSize.height)/2-polaroidHeight,
originalImgSize.width,originalImgSize.height);
if (allowCorners)
this.drawCorners(context, oImg, offsetX, offsetY);
context.restore();
}
};
mousemove
Canvas.Element.prototype.onMouseMove = function(e) {
var mp = this.findMousePosition(e);
if(this._aImages == null)
return;
if(this._currentTransform==null){
var targetImg = this.findTargetImage(mp, true);
this.setCursor(mp, targetImg);
}
else {
if (this._currentTransform.action == 'rotate') {
this.rotateImage(mp);
this.scaleImage(mp);
this.onRotateMove.fire(e);
}
else {
this.translateImage(mp);
this.onDragMove.fire(e);
}
this.renderTop();
}
};
里面的renderTop()
方法只在最上层canvas绘制当前操作的图片
Canvas.Element.prototype.renderTop = function() {
this._oContextTop.clearRect(0,0,parseInt(this._oConfig.width), parseInt(this._oConfig.height));
this.drawImageElement(this._oContextTop, this._aImages[this._aImages.length-1],true);
};
mouseup
Canvas.Element.prototype.onMouseUp = function(e) {
if (this._aImages == null) {
return;
}
var target=this._currentTransform.target;
if (target)
target.setImageCoords();//重置图片canvas封装
if(this._currentTransform!= null&&this._currentTransform.action=="rotate") {
this.onRotateComplete.fire(e);
} else if (this._currentTransform!=null&&this._currentTransform.action == "drag"){
this.onDragComplete.fire(e);
}
this._currentTransform = null;
this.renderTop();
if(this._aImages.length>0)//没有选中的图片
$('canvas_menu').style.display="block";
};
替换图片
<div id='canvas_menu'>
...
<a href='javascript:void(0)' id='puzzle_update'>更改图片</a>
</div>
avalon.bind($('puzzle_update'),'click',function(){
update_puzzle=true;
$('puzzle_add_input').click();
});
可以看到,这里依然使用了<input>
点击选中图片,不过设置了update_puzzle=true
,表示当前处在替换图片的情况下
function onSelect(file_filter){
for(var i=this._start,len=file_filter.length;i<len;i++) {
var reader=new FileReader();
reader.onload=(function(i){//图片读取的回调
return function(e){
...
img.onload = function() {
...
var t = window.setTimeout(function() {
if(!update_puzzle)
canvas_puzzle.init();
else{
//当前操作图片
var target=canvas._prevTransform.target;
//传入替换的图片和被替换图片的位置,状态信息
canvas._aImages[getCurImg()]=new canvasImg.Img(document.
querySelectorAll('.middleware_img')[0],{
top:target.top,
left:target.left,
scalex:target.scalex,
scaley:target.scaley,
angle:canvas.curAngle
});
//重新绘制最上层
canvas.renderTop();
html5_puzzle.middleware_list.clear();
update_puzzle=false;
}
clearTimeout(t);
}, 1000);
};
};
delete reader;
})(i);
reader.readAsDataURL(file_filter[i]);
}
...
}
function getCurImg(){//获取读取操作图片在_aImages中的位置
var oImg=canvas._prevTransform.target;
for(var i=0;i<canvas._aImages.length;i++){
if(canvas._aImages[i]._oElement.src==oImg._oElement.src){
return i;
}
}
}
删除图片
<div id='canvas_menu'>
<a href='javascript:void(0)' id='puzzle_delete'>删除</a>
...
</div>
avalon.bind($('puzzle_delete'),'click',function(){
canvas._aImages.splice(getCurImg(),1);//从_aImages数组中删除
canvas.renderAll(false,false);//重新绘制
$('canvas_menu').style.display="none";
...
});
拼图转换成base64字符串
Canvas.Element.prototype.canvasTo = function(format) {//canvas=>dataurl
this.renderAll(true,false);//所有图片都绘制到最上层,并且不绘制边框,边角
if (format == 'jpeg' || format == 'png') {
return this._oElement.toDataURL('image/'+format);
}
};
上传
avalon.post('...',{
imgData:canvas.canvasTo('jpeg').substr(22)
},function(data){
...
},'json');
后台用Base64解析imgData字符串就可以了
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。