【仰取俯拾】元素缩放、拖拽与打点定位项目实践浅谈

1,688 阅读5分钟

前言

近期在项目开发中遇到一个需求,即:实现在一张平面图可以通过鼠标进行缩放、拖拽与打点定位,并在图片拖拽缩放时实现点位相对平面图精准定位不偏移。

在对页面开发时也浏览过相关插件,如v-viewervue-drag-resize等,然而这些插件都不太符合开发需求。首先v-viewer这个插件作为图片预览插件算得上是非常优秀的插件了,几乎涵盖了图片预览所需功能,上个GitHub上的案例展示效果:

image.png

组件优秀是优秀,但挡不住我菜鸟本鸟的实质,起初通过修改插件样式与剔除弹窗将该预览插件包裹在限定范围元素里,却发现不能很好的适应当前开发需求。故该方案舍弃。

vue-drag-resize插件作为一款元素拖拽、缩放插件使用起来是非常接近开发需求了。下面也上一波GitHub案例展示效果:

image.png

然而它的不符合之处在于它的缩放操作是通过拖拽元素边框实现的,而需求是鼠标滚轮滚动进行缩放操作。本着改造轮子不如造轮子的执念故该方案也舍弃了。开始了我的粗制滥造之旅。

实现思路

  • 元素缩放
  1. 通过在目标元素上监听鼠标滚轮事件动态设置目标元素的样式,即css3属性:transform:scale(x, y),需求实现是等比例的故只需传第一个参数。
  • 元素拖拽
  1. 通过在目标元素上监听鼠标左键按下事件触发拖拽开始事件。
  2. 通过在window上监听鼠标移动事件触发拖拽进行事件。
  3. 通过在window上监听鼠标左键弹起事件触发拖拽结束事件。
  4. 通过鼠标移动的同时计算偏移动态设置目标元素的样式,即css3属性:transform:translate(x, y)。 注:为防止鼠标脱离目标元素会有问题,这里的事件绑定目标有一定的考究。必须是在目标元素上按下鼠标左键才可触发拖拽事件,并且鼠标移动与左键弹起事件绑定在window上,以防脱离目标元素时鼠标移动出现拖拽失效或者左键弹起拖拽不能结束的问题。
  • 元素打点
  1. 通过对鼠标左键点击的坐标与目标元素的尺寸与定位进行一定的计算,从而获取点位相对于目标元素的横纵偏移,将目标元素作为父元素动态添加子元素在其之上,并将获取的横纵偏移作为样式,即css3属性:transform:translate(x, y)追加到该子元素上。这里父元素的定位规则为:position:relative,子元素的定位规则为:position:absolute。注意:这里的横纵偏移要取百分比,否则会出现分辨率不同定位不同的问题。
  • 点位拖拽
  1. 原理同元素拖拽,不过要检测范围,不可让其偏移超出目标元素。
  • 点位设置
  1. 通过直接在文本框设置点位的css3属性:transform:translate(x, y)的x,y值改变点位的位置,为防止鼠标mousedown、mouseup与click冲突,故实际开发使用的鼠标双击事件dblclick。当然仍要使用单击事件click也可以通过鼠标按下时长做一个触发何种事件的判断。

实现过程

先上一段HTML代码、基础配置以及样式设置的逻辑代码以便有个更好的阅读体验(已去除无关代码,之后分析将只放JavaScript部分的代码),如下:

HTML片段:

<template>
  <div class="map-setting-wrap" onselectstart="return false">
    <!--  目标元素  -->
    <div
      ref="mapContainer"
      class="map-container"
      :style="style"
      @mousedown="dragMapStart"
      @dblclick="addPoint"
    >
      <img width="100%" :src="`${$fileURL}${imgUrl}`" alt="" :draggable="false" />
      <!--   点元素   -->
      <div
        v-for="(point, index) in points"
        :key="index + 1"
        class="map-point"
        :style="point.style"
        @mousedown.stop="dragPointStart($event, index)"
        @click="selectPoint(index)"
      >
        <img v-if="point.icon" class="map-point-icon" :src="point.icon" alt="" :draggable="false" />
      </div>
    </div>
    <!--  放大指示  -->
    <div v-show="showScaleNum" class="map-scale-num">{{scaleNum}}</div>
  </div>
</template>

基础数据配置片段:

// 基础配置
const mapOptions = {
  initScale: 1,
  minScale: 1,
  maxScale: 3,
  scaleStep: 0.1
}
// 点图标配置
const iconOptions = {
  '0': 'unknow',
  '1': 'mj',
  '10': 'mj',
  '11': 'mj',
  '12': 'gbysq',
  '2': 'clsb',
  '3': 'cgq',
  '4': 'td',
  '5': 'gbysq',
  '6': 'sxt',
  '7': 'yg',
  '8': 'wg',
  '9': 'mpzj',
  'doorway': 'crk'
}
export default {
  data() {
    return {
      imgUrl: '',
      timer: null,
      showScaleNum: false,
      scale: mapOptions.initScale,
      transformOriginX: 0,
      transformOriginY: 0,
      isDragMap: false,
      isDragPoint: false,
      startX: 0,
      startY: 0,
      moveX: 0,
      moveY: 0,
      pointsList: [],
      selectedIndex: 0
    }
  },
  mounted() {
    this.zoomHandle()
    this.dragEnd()
    this.dragging()
  }
}

样式设置片段(通过计算属性将样式设置操作与数值设置分离,以便更好的专注于进行数据变化操作,也使得代码逻辑更为清晰):

computed: {
  // 子元素点位设置
  points() {
    const points = this.pointsList.map(item => {
      const baseSrc = 'normal-icon'
      let icon = iconOptions[item.type]
      return {
        attributes: item,
        style: {
          transform: `scale(${1 / this.scale})`,
          transformOrigin: 'bottom',
          left: `calc(${item.xcoordinate}% - 20px)`,
          top: `calc(${item.ycoordinate}% - 50px)`,
          background: `url(${require(`@/assets/images/alarm/${baseSrc}/point.png`)})`
        },
        icon: icon ? `${require(`@/assets/images/alarm/${baseSrc}/${icon}.png`)}` : undefined
      }
    })
    return points
  },
  // 父元素样式设置
  style() {
    return {
      transform: `scale(${this.scale}) translate(${this.moveX}px, ${this.moveY}px)`,
      transformOrigin: `center`,
      // 拖拽不设置过渡,以防出现元素跟不上鼠标的情况
      transition: this.isDragMap ? 'none' : 'transform 0.3s'
    }
  },
  // 缩放数值提示
  scaleNum() {
    return `${Math.round(this.scale / mapOptions.initScale * 100)}%`
  }
},
  • 元素缩放

实现效果:

元素缩放.gif

实现代码:

// 缩放事件
zoomHandle() {
  this.$refs.mapContainer.addEventListener('wheel', e => {
    const isUp = e.wheelDelta > 0
    if (isUp) {
      this.zoomUpHandle(e)
    } else {
      this.zoomDnHandle(e)
    }
  })
},
// 放大按钮
zoomUpHandle(e) {
  this.setShowScaleNum()
  if (this.scale >= mapOptions.maxScale) {
    this.scale = mapOptions.maxScale
    return
  }
  this.scale += mapOptions.scaleStep
},
// 缩小按钮
zoomDnHandle(e) {
  this.setShowScaleNum()
  if (this.scale <= mapOptions.minScale) {
    this.scale = mapOptions.minScale
    return
  }
  this.scale -= mapOptions.scaleStep
},
// 显示缩放比
setShowScaleNum() {
  this.showScaleNum = true
  clearTimeout(this.timer)
  this.timer = setTimeout(() => {
    this.showScaleNum = false
  }, 1000)
},
  • 元素拖拽与点位拖拽

实现效果:

  1. 元素拖拽

元素拖拽.gif

  1. 点位拖拽

点位拖拽.gif

实现代码:

// 地图拖拽开始
dragMapStart(e) {
  this.isDragMap = true
  this.isDragPoint = false
  this.startX = e.clientX
  this.startY = e.clientY
},
// 点拖拽开始
dragPointStart(e, index) {
  this.selectedIndex = index
  this.isDragMap = false
  this.isDragPoint = true
},
// 地图或点拖拽结束
dragEnd() {
  window.addEventListener('mouseup', e => {
    this.isDragMap = false
    this.isDragPoint = false
  })
},
// 地图或点拖拽中
dragging() {
  window.addEventListener('mousemove', e => {
    if (this.isDragMap) {
      // 地图拖拽
      this.moveX += e.clientX - this.startX
      this.moveY += e.clientY - this.startY
      this.startX = e.clientX
      this.startY = e.clientY
      this.listenCollision()
    } else if (this.isDragPoint) {
      // 点拖拽
      const { xcoordinate, ycoordinate } = this.countCoordinate(e)
      this.pointsList[this.selectedIndex].xcoordinate = xcoordinate
      this.pointsList[this.selectedIndex].ycoordinate = ycoordinate
    }
  })
},
// 边缘碰撞检测
listenCollision() {
  const currentEl = this.$refs.mapContainer.getBoundingClientRect()
  const parentEl = this.$refs.mapContainer.parentElement.getBoundingClientRect()
  const maxLeft = parentEl.width - (currentEl.left - parentEl.left)
  const maxTop = parentEl.height - (currentEl.top - parentEl.top)
  const minLeft = -(currentEl.width + currentEl.left)
  const minTop = -(currentEl.height + currentEl.top)
  if (this.moveX >= maxLeft) {
    this.moveX = maxLeft
  }
  if (this.moveY >= maxTop) {
    this.moveY = maxTop
  }
  if (this.moveX <= minLeft) {
    this.moveX = minLeft
  }
  if (this.moveY <= minTop) {
    this.moveY = minTop
  }
},
// 计算鼠标坐标
countCoordinate(e) {
  const elAttr = this.$refs.mapContainer.getBoundingClientRect()
  let xcoordinate = Math.round((e.clientX - elAttr.left) / elAttr.width * 100)
  let ycoordinate = Math.round((e.clientY - elAttr.top) / elAttr.height * 100)
  xcoordinate = xcoordinate < 0 ? 0 : xcoordinate
  ycoordinate = ycoordinate < 0 ? 0 : ycoordinate
  xcoordinate = xcoordinate > 100 ? 100 : xcoordinate
  ycoordinate = ycoordinate > 100 ? 100 : ycoordinate
  return { xcoordinate, ycoordinate }
},
  • 元素打点

实现效果:

元素打点.gif

实现代码:

// 地图打点
addPoint(e) {
  const coordinate = this.countCoordinate(e)
  const xcoordinate = Math.round(coordinate.xcoordinate)
  const ycoordinate = Math.round(coordinate.ycoordinate)
  if (this.pointsList.some(point => point.xcoordinate === xcoordinate && point.ycoordinate === ycoordinate)) {
    this.$message.warning('该点位已存在,请勿重复打点!')
    return
  }
  this.pointsList.push({
    xcoordinate,
    ycoordinate
  })
}
  • 点位设置

实现效果:

点位设置.gif

实现代码:略(通过直接修改对应点位元素的横纵偏移即可)

可改进之处

  1. 元素缩放时可改进成以鼠标为中心,即将元素的css3属性transformOrigin:(x, y)设置为鼠标所在位置。注意:由于是相对定位,x,y应该是经过计算后的而非鼠标点击的坐标值。
  2. 可改进为两级父元素,最外层负责拖拽,内层负责缩放,以防拖拽缩放同时作用相同目标元素时,缩放比例不为1时拖拽速度比鼠标慢(缩放比小于1时)或比鼠标快(缩放比大于1时)。当然拖拽缩放同时作用相同目标元素时也可通过根据缩放比设置css3属性:transform:translate(x / this.scale, y / this.scale)防止该现象出现。
  3. 点位可改进成不随父元素缩放,即设置点缩放为1 / this.scale倍,使其始终为初始大小。注意:由于缩放比例未保持与父元素一致,缩放过程中将会出现点位相对父元素偏移的现象。还应该设置转换中心transform-origin使其符合指向性,否则会产生不符合预期的偏移。

以上内容如有纰漏或更优解,望不吝赐教~