6
最近工作中遇到一个需求,需要在一个图片上,手动框选一些区域,然后生成相对于图片区域的坐标。

于是想到之前用到过的一个裁剪图片的插件vue-cropper,一样是框选图片区域,于是学习了一下它的源码,并在此基础上自己实现了一个框选图片区域生成坐标的组件。

实现功能:

  1. 鼠标按下拖拽生成框选,不限制个数
  2. 可调整框选大小
  3. 可移动框选位置
  4. 可缩放
  5. 可单独控制每个框选是否可以编辑

先来看看效果:

video.gif


在实现功能之前我们要知道一些属性的区别:

2021081612622.jpeg

clientXclientY 表示鼠标相对于当前窗口的坐标,所以我们每次创建框选/改变框选大小/移动框选之前,都要先记录一下当时的clientX,clientY,然后在移动鼠标时用现在的clientX,clientY和之前记录的去对比,计算出鼠标移动的距离,以此来改变框选的宽度和位置,下面代码中的fwfh就是用来记录这个的。

offsetXoffsetY 是鼠标相对于绑定事件的那个元素,在这个组件中,绑定事件的是最外层的div元素,所以用这两个值,在创建框的时候来确定框选左上角的位置。

创建框选的步骤:
  1. 按下鼠标,触发mousedown事件,通过offsetX、offsetY来确定框选左上角在组件内的位置并记录,绑定mousemove,mouseup事件。
  2. 移动鼠标调整框选大小,触发mousemove事件,计算出新框选的左上角坐标和宽高。
  3. 鼠标抬起,触发mouseup事件,移除刚刚绑定的mousemove,mouseup事件。

每个框选有4个属性 cropXcropYcropWcropH 来记录框选的左上角坐标以及宽高,并有四个对应的old属性来记录旧坐标,记录旧坐标的目的是为了计算新坐标,栗子:

example.png

我们将框选拖大,如上图,那么新框选的左上角没变
cropW(新框选长度) = oldCropW + fw
cropH(新框选高度) = oldCrop + fh

再举个栗子,我们拖动框选右下角,把他拖到上方

example1.png

由上图所知:

  • cropW = fw - oldCropW
  • cropH = fh - oldCropH
  • cropX = oldCropX - cropW
  • cropY = oldCropY - cropH

改变框大小位置的计算逻辑大概就是这样,当然在计算坐标时还需要判断一下,不能让框选超出了范围。

代码如下:

<template>
  <div
      class="cropper-container"
      @mousedown.prevent="startMove"
  >
    <img
        :src="url"
        :style="{
          'width': currentWidth + 'px',
          'height': currentHeight + 'px'
        }"
        alt="背景图片"
    >
    <div
        v-for="(item,index) in list"
        :key="item.id"
        class="crop-box"
        :style="{
        'width': item.cropW + 'px',
        'height': item.cropH + 'px',
        'transform': 'translate3d('+ item.cropX + 'px,' + item.cropY + 'px,' + '0)'
      }"
    >
      <span
          class="cropper-face"
          @mousedown.prevent="cropMove($event,item)"
          @contextmenu.prevent="deleteCrop(index)"
      />

      <span v-show="item.canEdit">
        <span
            v-for="line in lineList"
            :key="line"
            :class="[`line-${line}`, 'crop-line']"
            @mousedown.prevent="changeCropSize($event, item, line)"
        />
        <span
            v-for="point in pointList"
            :key="point.index"
            :class="[`point${point.index}`, 'crop-point']"
            @mousedown.prevent="changeCropSize($event, item, point.position)"
        />
      </span>
    </div>
  </div>
</template>

<script>
export default {
  name: "cropper",
  model: {
    prop: 'list'
  },
  props: {
    url: {
      type: String,
      required: true
    },
    disabled: {
      type: Boolean,
      default: false
    },
    list: {
      type: Array,
      default: function () {
        return [];
      }
    },
    scale: {
      type: Number,
      default: 60
    },
    padding: { // 边框安全距离
      type: Number,
      default: 1
    }
  },
  data() {
    return {
      // 框的8个操作点list,左上 | 上 | 右上 | 右 | 右下 | 下 | 左下 | 左
      pointList: [
        {index: 1, position: ['left', 'top']},
        {index: 2, position: [null, 'top']},
        {index: 3, position: ['right', 'top']},
        {index: 4, position: ['right', null]},
        {index: 5, position: ['right', 'bottom']},
        {index: 6, position: [null, 'bottom']},
        {index: 7, position: ['left', 'bottom']},
        {index: 8, position: ['left', null]},
      ],
      lineList: ['left', 'top', 'right', 'bottom'], // 框的4条线
      trueWidth: 0, // 图片实际宽度
      trueHeight: 0, // 图片实际高度
      currentWidth: 0, // 图片当前宽度
      currentHeight: 0, // 图片当前高度
      tempCrop: {},
      changePosition: [], // 更改边的位置 ['left', 'top] 改上左两条边
      cropClientX: 0,
      cropClientY: 0,
    }
  },
  methods: {
    initImage(item) {
      let image = new Image();
      image.onload = () => {
        // 图片当前宽高
        this.currentWidth = image.width * this.scale / 100;
        this.currentHeight = image.height * this.scale / 100;
        // 图片真实宽高
        this.trueWidth = image.width;
        this.trueHeight = image.height;
        this.$emit('img', {
          width: image.width,
          height: image.height
        })
      };
      image.src = item;
    },
    startMove(e) {
      if (!this.disabled) {
        let item = {
          id: this.guid(),
          cropW: 0,
          cropH: 0,
          cropX: e.offsetX,
          cropY: e.offsetY,
          // 保存老坐标
          oldCropW: 0,
          oldCropH: 0,
          oldCropX: e.offsetX,
          oldCropY: e.offsetY,

          canEdit: true // 是否可以编辑
        };
        this.tempCrop = item;
        this.list.push(item);
        this.cropClientX = e.clientX;
        this.cropClientY = e.clientY;
        // 绑定截图事件
        window.addEventListener("mousemove", this.createCrop);
        window.addEventListener("mouseup", this.endCrop);
      }
    },
    // 创建剪裁框
    createCrop(e) {
      let nowX = e.clientX, nowY = e.clientY, item = this.tempCrop;
      let fw = nowX - this.cropClientX, fh = nowY - this.cropClientY;
      if (fw >= 0) {
        item.cropW = Math.min(this.currentWidth - item.oldCropX - 2 * this.padding, fw);
        item.cropX = item.oldCropX;
      } else {
        item.cropW = Math.min(item.oldCropX - 2 * this.padding, Math.abs(fw));
        item.cropX = Math.max(item.oldCropX + fw, this.padding);
      }
      if (fh >= 0) {
        item.cropH = Math.min(this.currentHeight - item.oldCropY - 2 * this.padding, fh);
        item.cropY = item.oldCropY;
      } else {
        item.cropH = Math.min(item.oldCropY - 2 * this.padding, Math.abs(fh));
        item.cropY = Math.max(item.oldCropY + fh, this.padding);
      }
      if (item.cropW > 10 && item.cropH > 10) {
        item.temp = false;
      }
    },
    // 创建完成
    endCrop() {
      window.removeEventListener("mousemove", this.createCrop);
      window.removeEventListener("mouseup", this.endCrop);
      this.tempCrop = {};
    },
    // 截图移动
    cropMove(e, item) {
      if (!this.disabled && item.canEdit) {

        this.cropClientX = e.clientX;
        this.cropClientY = e.clientY;
        item.oldCropW = item.cropW;
        item.oldCropH = item.cropH;
        item.oldCropX = item.cropX;
        item.oldCropY = item.cropY;
        this.tempCrop = item;

        window.addEventListener("mousemove", this.moveCrop);
        window.addEventListener("mouseup", this.leaveCrop);
      }
    },
    // 截图移动中
    moveCrop(e) {
      e.preventDefault();
      let nowX = e.clientX, nowY = e.clientY, item = this.tempCrop;
      let fw = nowX - this.cropClientX, fh = nowY - this.cropClientY;
      item.cropX = Math.min(Math.max(item.oldCropX + fw, this.padding), this.currentWidth - item.cropW - 2 * this.padding);
      item.cropY = Math.min(Math.max(item.oldCropY + fh, this.padding), this.currentHeight - item.cropH - 2 * this.padding);
    },
    // 截图移动结束
    leaveCrop() {
      window.removeEventListener("mousemove", this.moveCrop);
      window.removeEventListener("mouseup", this.leaveCrop);
    },
    // 删除框选
    deleteCrop(index) {
      if (!this.disabled) {
        this.list.splice(index, 1);
      }
    },
    // 改变截图框大小
    changeCropSize(e, item, position) {
      if (!this.disabled && item.canEdit) {
        window.addEventListener("mousemove", this.changeCropNow);
        window.addEventListener("mouseup", this.changeCropEnd);

        this.cropClientX = e.clientX;
        this.cropClientY = e.clientY;
        item.oldCropW = item.cropW;
        item.oldCropH = item.cropH;
        item.oldCropX = item.cropX;
        item.oldCropY = item.cropY;

        this.changePosition = position;
        this.tempCrop = item;
      }
    },
    // 正在改变大小
    changeCropNow(e) {
      e.preventDefault();
      let nowX = e.clientX, nowY = e.clientY, item = this.tempCrop, position = this.changePosition;
      let fw = nowX - this.cropClientX, fh = nowY - this.cropClientY;
      if (position.indexOf('left') > -1) { // 拖动的边中包含左边
        if (item.oldCropW - fw >= 0) {
          item.cropW = Math.min(item.oldCropW - fw, item.oldCropX + item.oldCropW);
          item.cropX = Math.max(this.padding, item.oldCropX + fw);
        } else {
          item.cropW = Math.min(fw - item.oldCropW, this.currentWidth - item.oldCropX - item.oldCropW - 2 * this.padding);
          item.cropX = item.oldCropX + item.oldCropW;
        }
      } else if (position.indexOf('right') > -1) { // 拖动的边中包含右边
        if (item.oldCropW + fw >= 0) {
          item.cropW = Math.min(this.currentWidth - item.cropX - 2 * this.padding, item.oldCropW + fw);
          item.cropX = item.oldCropX;
        } else {
          item.cropW = Math.min(Math.abs(fw + item.oldCropW), item.oldCropX - 2 * this.padding);
          item.cropX = Math.max(this.padding, item.oldCropX - item.cropW);
        }
      }

      if (position.indexOf('top') > -1) { // 拖动的边中包含上边
        if (item.oldCropH - fh > 0) { // 上方
          item.cropH = Math.min(item.oldCropH - fh, item.oldCropH + item.oldCropY - 2 * this.padding);
          item.cropY = Math.max(this.padding, item.oldCropY + fh);
        } else { // 下方
          item.cropH = Math.min(fh - item.oldCropH, this.currentHeight - item.oldCropY - item.oldCropH);
          item.cropY = item.oldCropY + item.oldCropH;
        }
      } else if (position.indexOf('bottom') > -1) { // 拖动的边中包含下边
        if (item.oldCropH + fh > 0) { // 下方
          item.cropH = Math.min(this.currentHeight - item.cropY - 2 * this.padding, item.oldCropH + fh);
          item.cropY = item.oldCropY;
        } else { // 上方
          item.cropH = Math.min(Math.abs(fh + item.oldCropH), item.oldCropY - 2 * this.padding);
          item.cropY = Math.max(this.padding, item.oldCropY + item.oldCropH + fh);
        }
      }
    },
    // 结束改变大小
    changeCropEnd() {
      window.removeEventListener("mousemove", this.changeCropNow);
      window.removeEventListener("mouseup", this.changeCropEnd);
    },
    guid() {
      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        let r = Math.random() * 16 | 0,
            v = c === 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
      });
    },
    changeCropScale(val, oldVal) {
      let scale = val / oldVal;
      this.list.forEach(item => {
        item.cropX = item.cropX * scale;
        item.cropY = item.cropY * scale;
        item.cropW = item.cropW * scale;
        item.cropH = item.cropH * scale;
      });
    },
  },
  watch: {
    url: {
      handler(val) {
        if (val) {
          this.initImage(val)
        }
      },
      immediate: true
    },
    scale(val, oldVal) {
      if (this.url) {
        this.currentWidth = this.trueWidth * val / 100;
        this.currentHeight = this.trueHeight * val / 100;
        this.changeCropScale(val, oldVal);
      }
    }
  }
}
</script>

<style scoped lang="scss">
.cropper-container {
  position: relative;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  user-select: none;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  cursor: crosshair;
}

.crop-box,
.cropper-face {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  user-select: none;
  box-sizing: border-box;
}

.crop-box {
  border: 1px solid #39f;
}

.cropper-face {
  top: 0;
  left: 0;
  cursor: move;
}

.crop-line {
  position: absolute;
  display: block;
  width: 100%;
  height: 100%;
}

.line-top {
  top: -3px;
  left: 0;
  height: 5px;
  cursor: row-resize;
}

.line-left {
  top: 0;
  left: -3px;
  width: 5px;
  cursor: col-resize;
}

.line-bottom {
  bottom: -3px;
  left: 0;
  height: 5px;
  cursor: row-resize;
}

.line-right {
  top: 0;
  right: -3px;
  width: 5px;
  cursor: col-resize;
}

.crop-point {
  position: absolute;
  width: 7px;
  height: 7px;
  opacity: .75;
  background-color: #39f;
  border-radius: 100%;
}

.point1 {
  top: -4px;
  left: -4px;
  cursor: nwse-resize;
}

.point2 {
  top: -4px;
  left: 50%;
  transform: translateX(-50%);
  cursor: row-resize;
}

.point3 {
  top: -4px;
  right: -4px;
  cursor: nesw-resize;
}

.point4 {
  top: 50%;
  right: -4px;
  transform: translateY(-50%);
  cursor: col-resize;
}

.point5 {
  bottom: -4px;
  right: -4px;
  cursor: nwse-resize;
}

.point6 {
  bottom: -4px;
  left: 50%;
  transform: translateX(-50%);
  cursor: row-resize;
}

.point7 {
  bottom: -4px;
  left: -4px;
  cursor: nesw-resize;
}

.point8 {
  top: 50%;
  left: -4px;
  transform: translateY(-50%);
  cursor: col-resize;
}
</style>

结尾

我是周小羊,一个前端萌新,写文章是为了记录自己日常工作遇到的问题和学习的内容,提升自己,如果您觉得本文对你有用的话,麻烦点个赞鼓励一下哟~

小绵羊
70 声望517 粉丝