Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

手把手教你写vue裁切预览组件 #1

Closed
qiuyaofan opened this issue May 3, 2018 · 1 comment
Closed

手把手教你写vue裁切预览组件 #1

qiuyaofan opened this issue May 3, 2018 · 1 comment
Labels
good first issue Good for newcomers

Comments

@qiuyaofan
Copy link
Owner

qiuyaofan commented May 3, 2018

vue版本裁切工具,包含预览功能

最终效果: https://qiuyaofan.github.io/vue-crop-demo/

源码地址: https://github.com/qiuyaofan/vue-crop

第一步:先用vue-cli安装脚手架(不会安装的看 vue-cli官网

// 初始化vue-cli
vue init webpack my-plugin

第二步:创建文件

新建src/views/validSlideDemo.vue,

src/components里新建VueCrop/index.js,VueCrop.vue,

在routes/index.js配置访问路由(具体看github源码)

最终生成的文件结构如下图:
vue-corp

第三步:注册组件

1.引用所有插件:src/components/index.js
// 导入插件入口文件
import VueCrop from './VueCrop/index.js'
const install = function (Vue, opts = {}) {
  /* 如果已安装就跳过 */
  if (install.installed) return
  
  // 注册插件
  Vue.component(VueCrop.name, VueCrop)
}

// 全局情况下注册插件
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export {
  install,
  // 此处是为了兼容在vue内单独引入这个插件,如果是main.js全局引入就可以去掉
  VueCrop
}
2.全局调用插件:src/main.js ( vue plugins官方文档解说install
import Vue from 'vue'
import App from './App'
import router from './router'

// 新加的:导入入口文件
import { install } from 'src/components/index.js'

// 全局调用,相当于调用 `MyPlugin.install(Vue)`
Vue.use(install)

Vue.config.productionTip = false
/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
})
3.VueCrop入口文件调用VueCrop.vue:src/components/VueCrop/index.js
// 导入vue
import VueCrop from './VueCrop.vue'

// Vue.js 的插件应当有一个公开方法 install 。这个方法的第一个参数是 Vue 构造器
VueCrop.install = function (Vue) {
  // 注册组件
  Vue.component(VueCrop.name, VueCrop)
}

export default VueCrop
小结:我一开始一直有个误解,以为myPlugin.install是vue的一个方法,其实不是,他只是我们构造plugin识的一个公开方法,可以理解为原生js中的构造函数的方法:
function MyPlugin(){
  console.info('构造函数')
}
MyPlugin.prototype.install=function(vue,options){
	console.info('构造器vue:'+vue);
}

而真正注册组件的是:Vue.component()

所以,vue插件注册的过程是:

1.调用main.js中:
import { install } from 'src/components/index.js'
vue.use(install)

2.index.js添加install方法,调用Vue.component注册组件

3.组件内的index.js同所有组件的index.js一样

第四步:设计开发自己的组件,构建组件结构

在此之前,可以先了解下组件的命名规范等,可参考文章 掘金:Vue前端开发规范,其中第2点有详细讲解

首先,确定自己的调用方式和需要暴露的参数

<vue-crop
:crop-url="cropUrl1"
:ratio="ratio"
:height="460"
:width="460"
:previewJson="previewJson1"
class="c-crop--preview_right"
@afterCrop="afterCrop"
>
>

其中,@afterCrop="afterCrop"是裁切完成的回调函数,其他是属性配置

在组件src/components/VueCrop/VueCrop.vue内,可以用this.$emit('afterCrop')触发demo里的afterCrop事件

组件结构上,主要分为:裁切主体部分(VueCrop.vue),选框组件(VueCropTool.vue),裁切框宽度、位置坐标等计算(VueCropMove.js),拖拽事件注册公共js(components/utils/draggable.js)

当前裁切插件的总体思路
  1. 裁切插件的裁切主体由图片,选框,预览结构组成
  2. 选框(VueCropTool.vue)负责拖拽改变其大小,坐标位置等并返回给VueCrop.vue
  3. 主体计算数值同步预览显示(c-crop--preview)
  4. 主体触发调用页面(VueCropDemo.vue)的afterCrop事件,从而传递参数返回裁切后的url,left,top,bottom,right,x,y,w,h等

备注:此组件不具备真实的裁切功能,最终的裁切是传递给后台去裁,你如果想扩展可以在afterCrop函数里根据坐标等信息进行处理

接下来我们对各个组件和js进行讲解

1.draggable.js是参照element里的,修改了一部分,源码如下
export default function (element, options) {
  const moveFn = function (event) {
    if (options.drag) {
      options.drag(event)
    }
  }
  // mousedown fn
  const downFn = function (event) {
    if (options.start) {
    	// 调用参数中start函数
      options.start(event)
    }
  }
  // mouseup fn
  const upFn = function (event) {
    document.removeEventListener('mousemove', moveFn)
    document.removeEventListener('mouseup', upFn)
    document.onselectstart = null
    document.ondragstart = null

    if (options.end) {
    	// 调用参数中end函数
      options.end(event)
    }
  }
  // 绑定事件
  element.addEventListener('mousedown', event => {
    if (options.stop && options.stop(event, element) === false) {
      return false
    }
    document.onselectstart = function () {
      return false
    }
    document.ondragstart = function () {
      return false
    }
    document.addEventListener('mousedown', downFn)
    document.addEventListener('mousemove', moveFn)
    document.addEventListener('mouseup', upFn)
  })
}

VueCropTool.vue使用如下
draggable(this.$el.querySelector('.c-crop--drap_screen'), {
	start: (event) => {
	  this.startPos = [event.x, event.y]
	},
	drag: (event) => {
	  this.handleDragLocation(event)
	},
	end: (event) => {
	  this.handleDragLocation(event)
	}
})
2.裁切主体部分(VueCrop.vue全部源码链接
//script部分
<script>
import VueCropTool from './VueCropTool.vue'
export default {
  name: 'VueCrop',
  data () {
    return {
      // 根据裁切后的缩放和坐标等生成的预览尺寸坐标数组
      previewImgSize: null,
      // 图片初始数据
      originImgSize: null,
      // 裁切框宽度
      elWidth: 0,
      // 裁切框高度
      elHeight: 0,
      // 裁切框top
      cursorTop: 0,
      // 裁切框left
      cursorLeft: 0,
      // 根据当前的容器宽高计算出的图片尺寸
      imgH: 0,
      imgW: 0,
      // 图片url
      url: this.cropUrl,
      // 为适应当前的容器对原始图片的缩放值
      scale: 1,
      // 根据当前选区和原始图片缩放前的尺寸,来得到最终的裁切尺寸
      coord: null,
      // 计算出的裁切框的初始值
      cropJson: {
        cw: null,
        ch: null,
        w: null,
        h: null,
        r: null
      }
    }
  },
  // 暴露出去的参数,具体解释可看前文的表格
  props: {
    cropUrl: String,
    // 比例
    ratio: {
      type: null,
      default: false
    },
    width: null,
    height: null,
    coordWidth: null,
    coordHeight: null,
    previewJson: {
      type: Array,
      default: function () {
        return []
      }
    }
  },
  components: {
    VueCropTool
  },
  created () {
  },
  watch: {
  	// 监听图片路径变化
    cropUrl (val) {
      this.url = val
      // setTimeout是为了兼容马上获取尺寸获取不到的情况
      setTimeout(() => {
        this.setSize()
      }, 200)
    }
  },
  methods: {
  	 // 更新拖拽尺寸,大部分由裁切框组件通过@updateSize触发
    drapSizeUpdate (w, h, t, l) {
      // 更新裁切框尺寸
      this.elWidth = w
      this.elHeight = h
      this.cursorTop = t
      this.cursorLeft = l
      // 根据当前选区获取原始图片缩放前的尺寸(还原原始图片的宽高以获取最终裁切数据)
      this.coord = this.getCoord(l, t, w, h)
      // 更新预览尺寸
      this.setPreviewSize(this.coord)
    },
    // 裁切完毕回调
    afterCrop () {
      this.$emit('afterCrop', this.coord, this.url)
    },
    // 设置preview尺寸
    setPreviewSize (coord) {
      if (!this.previewJson.length) {
        return false
      }
      let result = this.previewJson.map(data => {
      	 // 计算缩放比
        let scale = data.width / coord.w
        return {
          scale,
          l: -scale * coord.l,
          t: -scale * coord.t,
          w: scale * this.originImgSize.w,
          h: scale * this.originImgSize.h
        }
      })
      this.previewImgSize = result
    },
    // 设置裁切显示的图片尺寸,存储scale值
    async setSize () {
      if (!this.url) {
        return
      }
      let imgSize = await this.getSize(this.url)
      this.originImgSize = imgSize
      this.setCoordRange()
      this.scale = imgSize.w / this.imgW
      this.cursorTop = 0
      this.cursorLeft = 0
      let json = {...this.cropJson}
      json.w = this.imgW
      json.h = this.imgH
      // 有固定比例,则按比例截取
      if (this.ratio) {
        json.r = this.ratio
        if (json.w > json.h) {
          let r = json.h * this.ratio / json.w
          if (r > 1) {
            json.ch = json.h / r
            json.cw = json.ch * this.ratio
          } else {
            json.ch = json.h
            json.cw = json.ch * this.ratio
          }
        } else {
          let r = json.w / this.ratio / json.h
          if (r > 1) {
            json.cw = json.w / r
            json.ch = json.cw / this.ratio
          } else {
            json.cw = json.w
            json.ch = json.cw / this.ratio
          }
        }
      } else {
      	 // 无比例
        json.cw = json.w
        json.ch = json.h
      }
      // 裁切框的尺寸(/2是取一半的值,使裁切框居中并宽度为一半)
      this.elWidth = json.cw / 2
      this.elHeight = json.ch / 2
      this.cursorTop = json.ch / 4
      this.cursorLeft = json.cw / 4
      this.cropJson = {...json}
      this.drapSizeUpdate(this.elWidth, this.elHeight, this.cursorTop, this.cursorLeft)
    },
    // 根据图片原本的尺寸比例和用户传入的尺寸宽高设置当前可显示的区域图片尺寸
    setCoordRange () {
      var size = {...this.originImgSize}
      var ratio1 = this.width / this.height
      var ratio2 = size.r
      if (ratio2 > ratio1) {
        this.imgW = this.width
        this.imgH = this.width / size.r
      } else {
        this.imgH = this.height
        this.imgW = this.height * size.r
      }
    },
    // 获取裁切后的原始坐标宽高(裁切看到的宽高不是原始图片的宽高)
    getCoord (l, t, w, h) {
      l = this.scale * l
      t = this.scale * t
      w = this.scale * w
      h = this.scale * h
      return {
        p0: [l, t],
        p1: [l + w, t],
        p2: [l + w, t + h],
        p3: [l, t + h],
        w: w,
        h: h,
        l: l,
        t: t
      }
    },
    // 获取是src图片的尺寸
    getSize (src) {
      let _this = this
      let img = this.$el.querySelector('#c-crop--hide_img')
      return new Promise(resolve => {
        if (src && img) {
          img.onload = function () {
            const size = _this.getSizeImg(img)
            resolve(size)
          }
          img.src = src
        } else {
          resolve({
            w: 0,
            h: 0,
            r: 0
          })
        }
      })
    },
    // 获取原始图片的真实宽高、比例
    getSizeImg (img) {
      let w = img.width
      let h = img.height
      let r = w === 0 && h === 0 ? 0 : w / h
      return {
        w: w,
        h: h,
        r: r
      }
    }

  },
  mounted () {
    this.setSize()
  }

}

</script>


3.裁切框部分(VueCropTool.vue全部源码链接
//script部分

<script>
// 引入拖拽js
import draggable from '../utils/draggable'
// 引入裁切尺寸计算js
import movePos from './VueCropMove'
// 和VueCropMove有关,序号对应相应的操作,这些类名对应裁切框的四条边,四个角,四个边上的中点,拖拽由这12个位置进行
const dragEle = ['.c-crop--drap_eline', '.c-crop--drap_sline', '.c-crop--drap_wline', '.c-crop--drap_nline', '.c-crop--drap_e', '.c-crop--drap_s', '.c-crop--drap_w', '.c-crop--drap_n', '.c-crop--drap_ne', '.c-crop--drap_se', '.c-crop--drap_sw', '.c-crop--drap_nw']

export default {
  data () {
    return {
      width: this.elWidth,
      height: this.elHeight,
      top: this.cursorTop,
      left: this.cursorLeft,
      // 存储拖拽开始坐标(拖拽改变位置时)
      startPos: [0, 0],
      crop: [],
      // 计时器
      cropTimer: null,
      // 存储拖拽开始坐标尺寸(拖拽改变尺寸时)
      startSize: null
    }
  },
  props: ['elWidth', 'elHeight', 'cursorTop', 'cursorLeft', 'cropJson'],
  created () {},
  watch: {
    elWidth (val) {
      this.width = val
    },
    elHeight (val) {
      this.height = val
    },
    cursorTop (val) {
      this.top = val
    },
    cursorLeft (val) {
      this.left = val
    }
  },

  methods: {
    // 拖拽更新位置
    handleDragLocation (event) {
      let x = event.clientX
      let y = event.clientY
      this.left = x - this.startPos[0] + this.left
      this.top = y - this.startPos[1] + this.top
      this.startPos = [x, y]
      this.handleSize()
      // 更新尺寸
      this.$emit('updateSize', this.width, this.height, this.top, this.left)
      clearTimeout(this.cropTimer)
      // setTimeout是为了拖拽完成才调用afterCrop
      this.cropTimer = setTimeout(() => {
        // 调用回调
        this.$emit('afterCrop')
      }, 200)
    },
    // 拖拽改变位置:绑定事件
    dragCallLocation () {
      draggable(this.$el.querySelector('.c-crop--drap_screen'), {
        start: (event) => {
          this.startPos = [event.x, event.y]
        },
        drag: (event) => {
          this.handleDragLocation(event)
        },
        end: (event) => {
          this.handleDragLocation(event)
        }
      })
    },
    // 根据className获取父元素
    getParentElement (p, className) {
      const classNames = p.className
      if (classNames.indexOf(className) === -1) {
        p = p.parentNode
        return this.getParentElement(p, className)
      } else {
        return p
      }
    },
    // 获取拖拽的尺寸
    getDragSize (event) {
      const el = this.$el
      const screen = this.$cropArea.getBoundingClientRect()
      const rect = el.getBoundingClientRect()
      let json = {
        x: event.clientX,
        y: event.clientY,
        t: rect.top,
        b: rect.bottom,
        l: rect.left,
        r: rect.right,
        w: rect.width,
        h: rect.height,
        screen: screen
      }
      json.ratio = json.w / json.h
      return json
    },
    // 拖拽改变大小
    handleDrag (event, i) {
      // 获取坐标
      // console.info('move', i)
      const json = this.getDragSize(event)
      movePos[i](this, json, this.startSize)
      this.handleSize(true)
      this.$emit('updateSize', this.width, this.height, this.top, this.left)
      clearTimeout(this.cropTimer)
      this.cropTimer = setTimeout(() => {
        // 调用回调
        this.$emit('afterCrop')
      }, 200)
    },
    // 拖拽改变大小:绑定事件
    dragCall (i) {
      let target = this.$el.querySelector(dragEle[i])
      draggable(target, {
        start: (event) => {
          // 开始时拖拽框json
          this.startSize = this.getDragSize(event)
        },
        drag: (event) => {
          this.handleDrag(event, i)
        },
        end: (event) => {
          this.handleDrag(event, i)
        }
      })
    },
    // 改变位置大小
    handleSize (isSize) {
      this.left = range2(this.left, this.width, this.cropJson.w)
      this.top = range2(this.top, this.height, this.cropJson.h)
      if (isSize) {
        let d1 = this.cropJson.w - this.left
        let d2 = this.cropJson.h - this.top
        // 按比例裁切
        if (this.cropJson.r) {
          if (d1 < this.width) {
            this.width = d1
            this.height = this.width / this.cropJson.r
          } else if (d2 < this.height) {
            this.height = d2
            this.width = this.height * this.cropJson.r
          }
        } else {
          // 不按比例裁切
          if (d1 < this.width) {
            this.width = d1
          }
          if (d2 < this.height) {
            this.height = d2
          }
        }
      }
    }

  },
  mounted () {
    this.$cropArea = this.getParentElement(this.$el.parentNode, 'c-crop--area')
    // 初始化拖拽改变大小
    for (var i = 0; i < dragEle.length; i++) {
      this.dragCall(i)
    }
    // 初始化拖拽改变位置
    this.dragCallLocation()
  }
}

// 计算允许的范围
function range2 (pos, val, mainW) {
  return pos <= 0 ? 0 : pos > mainW - val ? mainW - val : pos
}

</script>
4.计算裁切框的js(VueCropMove.js全部源码链接
// 12种形态,四条边,边的中点,边的四个角。e:东,w:西,n:北,s:南,ne:东南以此类推
const movePos = {
  0: e,
  4: e,
  1: s,
  5: s,
  2: w,
  6: w,
  3: n,
  7: n,
  8: ne,
  9: se,
  10: sw,
  11: nw
}
let width, height, result, ratio

// 获取某种形态类型的宽或高最大值
function getMaxSize (json, startJson, dire, type) {
  if (type === 'w') {
    switch (dire) {
      case 'e':
      case 's':
      case 'n':
      case 'ne':
      case 'se':
        return json.screen.right - json.l
      case 'w':
      case 'nw':
      case 'sw':
        return startJson.r - json.screen.left
    }
  } else if (type === 'h') {
    switch (dire) {
      case 'n':
      case 'nw':
      case 'ne':
        return startJson.b - json.screen.top
      case 's':
      case 'w':
      case 'e':
      case 'sw':
      case 'se':
        return json.screen.bottom - startJson.t
    }
  }
}
// 判断是否有ratio,返回修改后的尺寸
function setRatioSize (type, json, startJson, ratio, width, height) {
  if (ratio) {
    if (width / ratio >= height) {
      var maxHeight = getMaxSize(json, startJson, type, 'h')
      height = width / ratio
      if (height > maxHeight) {
        height = maxHeight
        width = height * ratio
      }
    } else {
      var maxWidth = getMaxSize(json, startJson, type, 'w')
      width = height * ratio
      if (width > maxWidth) {
        width = maxWidth
        height = width / ratio
      }
    }
  }
  return {
    width: width,
    height: height
  }
}
// 拖拽东边,高度是不变的,除非有比例拖拽时
function e (_this, json, startJson) {
  ratio = _this.cropJson.r
  width = range(getWidth(json, startJson, 'e'), getMaxSize(json, startJson, 'e', 'w'))
  if (ratio) {
  	// 有比例时,计算高度,并对比最大值是否超出
    height = range(width / ratio, getMaxSize(json, startJson, 'e', 'h'))
    result = setRatioSize('e', json, startJson, ratio, width, height)
    setSize(_this, result)
  } else {
    _this.width = width
  }
  return _this
}

// 拖拽南边,宽度是不变的,除非有比例拖拽时
function s (_this, json, startJson) {
  ratio = _this.cropJson.r
  height = range(getHeight(json, startJson, 's'), getMaxSize(json, startJson, 's', 'h'))
  if (ratio) {
    // 有比例时,计算宽度,并对比最大值是否超出
    width = range(height * ratio, getMaxSize(json, startJson, 's', 'w'))
    result = setRatioSize('s', json, startJson, ratio, width, height)
    setSize(_this, result)
  } else {
    _this.height = height
  }

  return _this
}

// 以下同上,以此类推
function w (_this, json, startJson) {
  ratio = _this.cropJson.r
  width = range(getWidth(json, startJson, 'w'), getMaxSize(json, startJson, 'w', 'w'))
  if (ratio) {
    height = range(width / ratio, getMaxSize(json, startJson, 'w', 'h'))
    result = setRatioSize('w', json, startJson, ratio, width, height)
    setSize(_this, result)
    _this.left = getLeft(_this, json, startJson)
  } else {
    _this.width = width
    _this.left = rangeMax(json.x - json.screen.left, startJson.r)
  }
  return _this
}
function n (_this, json, startJson) {
  ratio = _this.cropJson.r
  height = range(getHeight(json, startJson, 'n'), getMaxSize(json, startJson, 'n', 'h'))
  if (ratio) {
    width = range(height * ratio, getMaxSize(json, startJson, 'n', 'w'))
    result = setRatioSize('n', json, startJson, ratio, width, height)
    setSize(_this, result)
    _this.top = getTop(_this, json, startJson)
  } else {
    _this.height = height
    _this.top = rangeMax(json.y - json.screen.top, startJson.b)
  }
  return _this
}

function ne (_this, json, startJson) {
  height = range(getHeight(json, startJson, 'n'), getMaxSize(json, startJson, 'ne', 'h'))
  width = range(getWidth(json, startJson, 'e'), getMaxSize(json, startJson, 'ne', 'w'))
  result = setRatioSize('ne', json, startJson, _this.cropJson.r, width, height)
  setSize(_this, result)
  _this.top = getTop(_this, json, startJson)
  return _this
}
function se (_this, json, startJson) {
  height = range(getHeight(json, startJson, 's'), getMaxSize(json, startJson, 'se', 'h'))
  width = range(getWidth(json, startJson, 'e'), getMaxSize(json, startJson, 'se', 'w'))
  result = setRatioSize('se', json, startJson, _this.cropJson.r, width, height)
  setSize(_this, result)
  return _this
}
function sw (_this, json, startJson) {
  width = range(getWidth(json, startJson, 'w'), getMaxSize(json, startJson, 'sw', 'w'))
  height = range(getHeight(json, startJson, 's'), getMaxSize(json, startJson, 'sw', 'h'))
  result = setRatioSize('sw', json, startJson, _this.cropJson.r, width, height)
  setSize(_this, result)
  _this.left = getLeft(_this, json, startJson)
  return _this
}
function nw (_this, json, startJson) {
  width = range(getWidth(json, startJson, 'w'), getMaxSize(json, startJson, 'nw', 'w'))
  height = range(getHeight(json, startJson, 'n'), getMaxSize(json, startJson, 'nw', 'h'))
  result = setRatioSize('nw', json, startJson, _this.cropJson.r, width, height)
  setSize(_this, result)
  _this.left = getLeft(_this, json, startJson)
  _this.top = getTop(_this, json, startJson)
  return _this
}

// 匹配范围
function range (value, max) {
  value = value > max ? max : value
  return value < 20 ? 20 : value
}
// 最大值
function rangeMax (value, max) {
  return value > max ? max : value
}
// top
function getTop (_this, json, startJson) {
  return rangeMax(startJson.b - _this.height - json.screen.top, startJson.b)
}
// left
function getLeft (_this, json, startJson) {
  return rangeMax(startJson.r - _this.width - json.screen.left, startJson.r)
}
// height:只存在于s||n类型
function getHeight (json, startJson, type) {
  return type === 'n' ? startJson.b - json.y : json.y - startJson.t
}
// width:只存在于w||e类型
function getWidth (json, startJson, type) {
  return type === 'w' ? startJson.r - json.x : json.x - startJson.l
}
// setSize
function setSize (_this, result) {
  _this.width = result.width
  _this.height = result.height
}

export default movePos

@qiuyaofan qiuyaofan added the good first issue Good for newcomers label May 3, 2018
@peijunpeng
Copy link

选择图片后,再去选择更换图片,然后取消,看控制台

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
good first issue Good for newcomers
Projects
None yet
Development

No branches or pull requests

2 participants