基于Vue的移动端图片裁剪组件功能


Posted in Javascript onNovember 28, 2017

最近项目上要做一个车牌识别的功能。本来以为很简单,只需要将图片扔给后台就可以了,但是经测试后识别率只有20-40%。因此产品建议拍摄图片后,可以对图片进行拖拽和缩放,然后裁剪车牌部分上传给后台来提高识别率。刚开始的话还是百度了一下看看有没有现成的组件,但是找来找去都没有找到一个合适的,还好这个功能不是很着急,因此自己周末就在家里研究一下。

Demo地址:https://vivialex.github.io/demo/imageClipper/index.html

下载地址:https://github.com/vivialex/vue-imageClipper

因为移动端是用vue,所以就写成了一个vue组件,下面就说说自己的一些实现思路(本人技术有限,各位大神请体谅。另外展示的代码不一定是某个功能的完整代码),先看看效果: 

基于Vue的移动端图片裁剪组件功能基于Vue的移动端图片裁剪组件功能  

一、组件的初始化参数

1、图片img(url或者base64 data-url)

2、截图的宽clipperImgWidth

3、截图的高clipperImgHeight

props: {
  img: String, //url或dataUrl
  clipperImgWidth: {
    type: Number,
    default: 500
  },
  clipperImgHeight: {
    type: Number,
    default: 200
  }
}

  二、布局

在Z轴方向看主要是由4层组成。第1层是一个占满整个容器的canvas(称cCanvas);第2层是一个有透明度的遮罩层;第3层是裁剪的区域(示例图中的白色方框),里面包含一个与裁剪区域大小相等的canvas(称pCanvas);第4层是一个透明层gesture-mask,用作绑定touchstart,touchmove,touchend事件。其中两个canvas都会加载同一张图片,只是起始坐标不一样。为什么需要两个canvas?因为想做出当手指离开屏幕时,裁剪区域外的部分表面会有一个遮罩层的效果,这样能突出裁剪区域的内容。

<div class="cut-container" ref="cut">
  <canvas ref="canvas"></canvas>
  <!-- 裁剪部分 -->
  <div class="cut-part">
    <div class="pCanvas-container">
      <canvas ref="pCanvas"></canvas>
    </div>
  </div>
  <!-- 底部操作栏 -->
  <div class="action-bar">
    <button class="btn-cancel" @click="_cancel">取消</button>
    <button class="btn-ok" @click="_cut">确认</button>
  </div>
  <!-- 背景遮罩 -->
  <div class="mask" :class="{opacity: maskShow}"></div>
  <!-- 手势操作层 -->
  <div class="gesture-mask" ref="gesture"></div>
</div>

  三、初始化canvas

canvas绘制的图片在hdpi显示屏上会出现模糊,具体原因这里不作分析,可以参考下这里。我这里的做法是让canvas的width与height为其css width/height的devicePixelRatio倍,以及调用canvas api时所传入的参数都要乘以window.devicePixelRatio。最后还要记录一下两个canvas坐标原点的x, y差值(originXDiff与originYDiff)。如下

_ratio(size) {
  return parseInt(window.devicePixelRatio * size);
},
_initCanvas() {
  let $canvas = this.$refs.canvas,
    $pCanvas = this.$refs.pCanvas,
    clipperClientRect = this.$refs.clipper.getBoundingClientRect(),
    clipperWidth = parseInt(this.clipperImgWidth / window.devicePixelRatio),
    clipperHeight = parseInt(this.clipperImgHeight / window.devicePixelRatio);

  this.ctx = $canvas.getContext('2d');
  this.pCtx = $pCanvas.getContext('2d');

  //判断clipperWidth与clipperHeight有没有超过容器值
  if (clipperWidth < 0 || clipperWidth > clipperClientRect.width) {
    clipperWidth = 250
  }

  if (clipperHeight < 0 || clipperHeight > clipperClientRect.height) {
    clipperHeight = 100
  }

  //因为canvas在手机上会被放大,因此里面的内容会模糊,这里根据手机的devicePixelRatio来放大canvas,然后再通过设置css来收缩,因此关于canvas的所有值或坐标都要乘以devicePixelRatio
  $canvas.style.width = clipperClientRect.width + 'px';
  $canvas.style.height = clipperClientRect.height + 'px';
  $canvas.width = this._ratio(clipperClientRect.width);
  $canvas.height = this._ratio(clipperClientRect.height);

  $pCanvas.style.width = clipperWidth + 'px';
  $pCanvas.style.height = clipperHeight + 'px';
  $pCanvas.width = this._ratio(clipperWidth);
  $pCanvas.height = this._ratio(clipperHeight);

  //计算两个canvas原点的x y差值
  let cClientRect = $canvas.getBoundingClientRect(),
    pClientRect = $pCanvas.getBoundingClientRect();

  this.originXDiff = pClientRect.left - cClientRect.left;
  this.originYDiff = pClientRect.top - cClientRect.top;
  this.cWidth = cClientRect.width;
  this.cHeight = cClientRect.height;
}

  四、加载图片

加载图片比较简单,首先是创建一个Image对象并监听器onload事件(因为加载的图片有可能是跨域的,因此要设置其crossOrigin属性为Anonymous,然后服务器上要设置Access-Control-Allow-Origin响应头)。加载的图片如果宽高大于容器的宽高,要对其进行缩小处理。最后垂直水平居中显示()(这里注意的是要保存图片绘制前的宽高值,因为日后缩放图片是以该值为基础再乘以缩放倍率,这里取imgStartWidth,imgStartHeight)如下

_loadImg() {
  if (this.imgLoading || this.loadImgQueue.length === 0) {
    return;
  }
  let img = this.loadImgQueue.shift();
  if (!img) {
    return;
  }
  let $img = new Image(),
    onLoad = e => {
      $img.removeEventListener('load', onLoad, false);
      this.$img = $img;
      this.imgLoaded = true;
      this.imgLoading = false;
      this._initImg($img.width, $img.height);
      this.$emit('loadSuccess', e);
      this.$emit('loadComplete', e);
      this._loadImg();
    },
    onError = e => {
      $img.removeEventListener('error', onError, false);
      this.$img = $img = null;
      this.imgLoading = false;
      this.$emit('loadError', e);
      this.$emit('loadComplete', e);
      this._loadImg();
    };
  this.$emit('beforeLoad');
  this.imgLoading = true;
  this.imgLoaded = false;
  $img.src = this.img;
  $img.crossOrigin = 'Anonymous'; //因为canvas toDataUrl不能操作未经允许的跨域图片,这需要服务器设置Access-Control-Allow-Origin头
  $img.addEventListener('load', onLoad, false);
  $img.addEventListener('error', onError, false);
}
_initImg(w, h) {
  let eW = null,
    eH = null,
    maxW = this.cWidth,
    maxH = this.cHeight - this.actionBarHeight;
  //如果图片的宽高都少于容器的宽高,则不做处理
  if (w <= maxW && h <= maxH) {
    eW = w;
    eH = h;
  } else if (w > maxW && h <= maxH) {
    eW = maxW;
    eH = parseInt(h / w * maxW);
  } else if (w <= maxW && h > maxH) {
    eW = parseInt(w / h * maxH);
    eH = maxH;
  } else {
    //判断是横图还是竖图
    if (h > w) {
      eW = parseInt(w / h * maxH);
      eH = maxH;
    } else {
      eW = maxW;
      eH = parseInt(h / w * maxW);
    }
  }
  if (eW <= maxW && eH <= maxH) {
    //记录其初始化的宽高,日后的缩放功能以此值为基础
    this.imgStartWidth = eW;
    this.imgStartHeight = eH;
    this._drawImage((maxW - eW) / 2, (maxH - eH) / 2, eW, eH);
  } else {
    this._initImg(eW, eH);
  }
}

五、绘制图片

下面的_drawImage有四个参数,分别是图片对应cCanvas的x,y坐标以及图片目前的宽高w,h。函数首先会清空两个canvas的内容,方法是重新设置canvas的宽高。然后更新组件实例中对应的值,最后再调用两个canvas的drawImage去绘制图片。对于pCanvas来说,其绘制的图片坐标值为x,y减去对应的originXDiff与originYDiff(其实相当于切换坐标系显示而已,因此只需要减去两个坐标系原点的x,y差值即可)。看看代码

_drawImage(x, y, w, h) {
  this._clearCanvas();
  this.imgX = parseInt(x);
  this.imgY = parseInt(y);
  this.imgCurrentWidth = parseInt(w);
  this.imgCurrentHeight = parseInt(h);
  //更新canvas
  this.ctx.drawImage(this.$img, this._ratio(x), this._ratio(y), this._ratio(w), this._ratio(h));
  //更新pCanvas,只需要减去两个canvas坐标原点对应的差值即可
  this.pCtx.drawImage(this.$img, this._ratio(x - this.originXDiff), this._ratio(y - this.originYDiff), this._ratio(w), this._ratio(h));
},
_clearCanvas() {
  let $canvas = this.$refs.canvas,
    $pCanvas = this.$refs.pCanvas;
  $canvas.width = $canvas.width;
  $canvas.height = $canvas.height;
  $pCanvas.width = $pCanvas.width;
  $pCanvas.height = $pCanvas.height;
}

六、移动图片

移动图片实现非常简单,首先给gesture-mask绑定touchstart,touchmove,touchend事件,下面分别介绍这三个事件的内容

首先定义四个变量scx, scy(手指的起始坐标),iX,iY(图片目前的坐标,相对于cCanvas)。

1、touchstart

方法很简单,就是获取touches[0]的pageX,pageY来更新scx与scy以及更新iX与iY

2、touchmove

获取touches[0]的pageX,声明变量f1x存放,移动后的x坐标等于iX + f1x - scx,y坐标同理,最后调用_drawImage来更新图片。

看看代码吧

_initEvent() {
  let $gesture = this.$refs.gesture,
    scx = 0,
    scy = 0;
  let iX = this.imgX,
    iY = this.imgY;
  $gesture.addEventListener('touchstart', e => {
    if (!this.imgLoaded) {
      return;
    }
    let finger = e.touches[0];
      scx = finger.pageX;
      scy = finger.pageY;
      iX = this.imgX;
      iY = this.imgY;  
  }, false);
  $gesture.addEventListener('touchmove', e => {
    e.preventDefault();
    if (!this.imgLoaded) {
      return;
    }
    let f1x = e.touches[0].pageX,
      f1y = e.touches[0].pageY;
      this._drawImage(iX + f1x - scx, iY + f1y - scy, this.imgCurrentWidth, this.imgCurrentHeight);
  }, false);
}

七、缩放图片(这里不作特别说明的坐标都是相对于cCanvas坐标系)

绘制缩放后的图片无非需要4个参数,缩放后图片左上角的坐标以及宽高。求宽高相对好办,宽高等于imgStartWidth * 缩放比率与imgstartHeight * 缩放倍率(imgStartWidth ,imgstartHeight 上文第四节有提到)。接下来就是求缩放倍率的问题了,首先在touchstart事件上求取两手指间的距离d1;然后在touchmove事件上继续求取两手指间的距离d2,当前缩放倍率= 初始缩放倍率 + (d2-d1) / 步长(例如每60px算0.1),touchend事件上让初始缩放倍率=当前缩放倍率。

至于如何求取缩放后图片左上角的坐标值,在草稿纸上画来画去,画了很久......终于有点眉目。首先要找到一个缩放中心(这里做法是取双指的中点坐标,但是这个坐标必须要位于图片上,如果不在图片上,则取图片上离该中点坐标最近的点),然后存在下面这个等式

(缩放中心x坐标 - 缩放后图片左上角x坐标)/ 缩放后图片的宽度 = (缩放中心x坐标 - 缩放前图片左上角x坐标)/ 缩放前图片的宽度;(y坐标同理)

接下来看看下面这个例子(在visio找了很久都没有画坐标系的功能,所以只能手工画了)

基于Vue的移动端图片裁剪组件功能

绿色框是一张10*5的图片,蓝色框是宽高放大两倍后的图片20*10,根据上面的公式推算的x2 = sx - w2(sx - x1) / w1,y2 = sy - h2(sy - y1) / h1。

坚持...继续看看代码吧

_initEvent() {
  let $gesture = this.$refs.gesture,
    cClientRect = this.$refs.canvas.getBoundingClientRect(),
    scx = 0, //对于单手操作是移动的起点坐标,对于缩放是图片距离两手指的中点最近的图标。
    scy = 0,
    fingers = {}; //记录当前有多少只手指在触控屏幕
  //one finger
  let iX = this.imgX,
    iY = this.imgY;
  //two finger
  let figureDistance = 0,
    pinchScale = this.imgScale;
  $gesture.addEventListener('touchstart', e => {
    if (!this.imgLoaded) {
      return;
    }
    if (e.touches.length === 1) {
      let finger = e.touches[0];
      scx = finger.pageX;
      scy = finger.pageY;
      iX = this.imgX;
      iY = this.imgY;
      fingers[finger.identifier] = finger;
    } else if (e.touches.length === 2) {
      let finger1 = e.touches[0],
        finger2 = e.touches[1],
        f1x = finger1.pageX - cClientRect.left,
        f1y = finger1.pageY - cClientRect.top,
        f2x = finger2.pageX - cClientRect.left,
        f2y = finger2.pageY - cClientRect.top;
      scx = parseInt((f1x + f2x) / 2);
      scy = parseInt((f1y + f2y) / 2);
      figureDistance = this._pointDistance(f1x, f1y, f2x, f2y);
      fingers[finger1.identifier] = finger1;
      fingers[finger2.identifier] = finger2;
      //判断变换中点是否在图片中,如果不是则去离图片最近的点
      if (scx < this.imgX) {
        scx = this.imgX;
      }
      if (scx > this.imgX + this.imgCurrentWidth) {
        scx = this.imgX + this.imgCurrentHeight;
      }
      if (scy < this.imgY) {
        scy = this.imgY;
      }
      if (scy > this.imgY + this.imgCurrentHeight) {
        scy = this.imgY + this.imgCurrentHeight;
      }
    }
  }, false);
  $gesture.addEventListener('touchmove', e => {
    e.preventDefault();
    if (!this.imgLoaded) {
      return;
    }
    this.maskShowTimer && clearTimeout(this.maskShowTimer);
    this.maskShow = false;
    if (e.touches.length === 1) {
      let f1x = e.touches[0].pageX,
        f1y = e.touches[0].pageY;
      this._drawImage(iX + f1x - scx, iY + f1y - scy, this.imgCurrentWidth, this.imgCurrentHeight);
    } else if (e.touches.length === 2) {
      let finger1 = e.touches[0],
        finger2 = e.touches[1],
        f1x = finger1.pageX - cClientRect.left,
        f1y = finger1.pageY - cClientRect.top,
        f2x = finger2.pageX - cClientRect.left,
        f2y = finger2.pageY - cClientRect.top,
        newFigureDistance = this._pointDistance(f1x, f1y, f2x, f2y),
        scale = this.imgScale + parseFloat(((newFigureDistance - figureDistance) / this.imgScaleStep).toFixed(1));
      fingers[finger1.identifier] = finger1;
      fingers[finger2.identifier] = finger2;
      if (scale !== pinchScale) {
        //目前缩放的最小比例是1,最大是5
        if (scale < this.imgMinScale) {
          scale = this.imgMinScale;
        } else if (scale > this.imgMaxScale) {
          scale = this.imgMaxScale;
        }
        pinchScale = scale;
        this._scale(scx, scy, scale);
      }
    }
  }, false);
  $gesture.addEventListener('touchend', e => {
    if (!this.imgLoaded) {
      return;
    }
    this.imgScale = pinchScale;
    //从finger删除已经离开的手指
    let touches = Array.prototype.slice.call(e.changedTouches, 0);
    touches.forEach(item => {
      delete fingers[item.identifier];
    });
    //迭代fingers,如果存在finger则更新scx,scy,iX,iY,因为可能缩放后立即单指拖动
    let i,
      fingerArr = [];
    for(i in fingers) {
      if (fingers.hasOwnProperty(i)) {
        fingerArr.push(fingers[i]);
      }
    }
    if (fingerArr.length > 0) {
      scx = fingerArr[0].pageX;
      scy = fingerArr[0].pageY;
      iX = this.imgX;
      iY = this.imgY;
    } else {
      this.maskShowTimer = setTimeout(() => {
        this.maskShow = true;
      }, 300);
    }
    //做边界值检测
    let x = this.imgX,
      y = this.imgY,
      pClientRect = this.$refs.pCanvas.getBoundingClientRect();
    if (x > pClientRect.left + pClientRect.width) {
      x = pClientRect.left
    } else if (x + this.imgCurrentWidth < pClientRect.left) {
      x = pClientRect.left + pClientRect.width - this.imgCurrentWidth;
    }
    if (y > pClientRect.top + pClientRect.height) {
      y = pClientRect.top;
    } else if (y + this.imgCurrentHeight < pClientRect.top) {
      y = pClientRect.top + pClientRect.height - this.imgCurrentHeight;
    }
    if (this.imgX !== x || this.imgY !== y) {
      this._drawImage(x, y, this.imgCurrentWidth, this.imgCurrentHeight);
    }
  });
},
_scale(x, y, scale) {
  let newPicWidth = parseInt(this.imgStartWidth * scale),
    newPicHeight = parseInt(this.imgStartHeight * scale),
    newIX = parseInt(x - newPicWidth * (x - this.imgX) / this.imgCurrentWidth),
    newIY = parseInt(y - newPicHeight * (y - this.imgY) / this.imgCurrentHeight);
  this._drawImage(newIX, newIY, newPicWidth, newPicHeight);
},
_pointDistance(x1, y1, x2, y2) {
  return parseInt(Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)));
}

说明一下fingers是干嘛的,是用来记录当前有多少只手指在屏幕上触摸。可能会出现这种情况,双指缩放后,其中一只手指移出显示屏,而另外一个手指在显示屏上移动。针对这种情况,要在touchend事件上根据e.changedTouches来移除fingers里已经离开显示屏的finger,如果此时fingers里只剩下一个finger,则更新scx,scy,iX,iY为移动图片做初始化准备。

八、裁剪图片

这里很简单,就调用pCanvas的toDataURL方法就可以了

_clipper() {
  let imgData = null;
  try {
    imgData = this.$refs.pCanvas.toDataURL();
  } catch (e) {
    console.error('请在response header加上Access-Control-Allow-Origin,否则canvas无法裁剪未经许可的跨域图片');
  }
  this.$emit('sure', imgData);
}

   总结

  上面只是列出了一些自己认为比较关键的点, 如果有兴趣的,可以到我的github上下载源码看看。

以上所述是小编给大家介绍的基于Vue的移动端图片裁剪组件功能,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对三水点靠木网站的支持!

Javascript 相关文章推荐
use jscript Create a SQL Server database
Jun 16 Javascript
Prototype Number对象 学习
Jul 19 Javascript
javascript时间自动刷新实现原理与步骤
Jan 06 Javascript
JavaScript调试技巧之console.log()详解
Mar 19 Javascript
ie9 提示'console' 未定义问题的解决方法
Mar 20 Javascript
使用script的src实现跨域和类似ajax效果
Nov 10 Javascript
javascript每日必学之继承
Feb 23 Javascript
jquery.multiselect多选下拉框实现代码
Nov 11 Javascript
详解vue跨组件通信的几种方法
Jun 15 Javascript
Vue2.0父组件与子组件之间的事件发射与接收实例代码
Sep 19 Javascript
微信小程序自定义组件的实现方法及自定义组件与页面间的数据传递问题
Oct 09 Javascript
js中位数不足自动补位扩展padLeft、padRight实现代码
Apr 06 Javascript
javaScript canvas实现(画笔大小 颜色 橡皮的实例)
Nov 28 #Javascript
基于Vue框架vux组件库实现上拉刷新功能
Nov 28 #Javascript
JavaScript中关于class的调用方法
Nov 28 #Javascript
基于vue+canvas的excel-like组件实例详解
Nov 28 #Javascript
JS原型继承四步曲及原型继承图一览
Nov 28 #Javascript
weebox弹出窗口不居中显示的解决方法
Nov 27 #Javascript
Dropify.js图片宽高自适应的方法
Nov 27 #Javascript
You might like
PHP网站安装程序制作的原理、步骤、注意事项和示例代码
2010/08/01 PHP
php基于自定义函数记录log日志方法
2017/07/21 PHP
Laravel框架中VerifyCsrfToken报错问题的解决
2017/08/30 PHP
javascript 面向对象编程  function是方法(函数)
2009/09/17 Javascript
IE6不能修改NAME问题的解决方法
2010/09/03 Javascript
node.js中的fs.lchownSync方法使用说明
2014/12/16 Javascript
深入理解JavaScript系列(41):设计模式之模板方法详解
2015/03/04 Javascript
JavaScript获取按钮所在form表单id的方法
2015/04/02 Javascript
jQuery数据类型小结(14个)
2016/01/08 Javascript
常用的js验证和数据处理总结
2016/08/02 Javascript
JS中from 表单序列化提交的代码
2017/01/20 Javascript
老生常谈js中0到底是 true 还是 false
2017/03/08 Javascript
javascript兼容性(实例讲解)
2017/08/15 Javascript
通过button将form表单的数据提交到action层的实例
2017/09/08 Javascript
微信小程序之数据缓存的实例详解
2017/09/29 Javascript
Vue 实现拨打电话操作
2020/11/16 Javascript
[01:32]TI珍贵瞬间系列(一)
2020/08/26 DOTA
python复制文件代码实现
2013/12/23 Python
Python中使用hashlib模块处理算法的教程
2015/04/28 Python
Python的Asyncore异步Socket模块及实现端口转发的例子
2016/06/14 Python
详解python3百度指数抓取实例
2016/12/12 Python
通过shell+python实现企业微信预警
2019/03/07 Python
使用Pytorch来拟合函数方式
2020/01/14 Python
python字典和json.dumps()的遇到的坑分析
2020/03/11 Python
python实现单机五子棋
2020/08/28 Python
pycharm进入时每次都是insert模式的解决方式
2021/02/05 Python
CSS3伪类选择器:nth-child()
2009/04/02 HTML / CSS
HTML5学习心得总结(推荐)
2016/07/08 HTML / CSS
365 Tickets英国:全球景点门票
2019/07/06 全球购物
Hibernate持久层技术
2013/12/16 面试题
学前教育教师求职自荐信
2013/09/22 职场文书
简历自我评价怎么写呢?
2014/01/06 职场文书
培训班开班仪式主持词
2014/03/28 职场文书
2014新生大学四年计划书
2014/09/21 职场文书
2014年团支部年度工作总结
2014/12/24 职场文书
物业公司管理制度
2015/08/05 职场文书