让元素可以在页面上用鼠标移动起来

487 阅读5分钟

想要让元素可以在浏览器的视窗中移动起来,首先最重要的一步就是,圈定可移动的范围

css 样式的核心代码

  // 父容器核心样式
  position: fixed;
  width: 100%;
  height: 100%;

  // 子容器核心样式
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%,-50%);

父容器通过 width && height 字段占满整个浏览器的可视范围,子容器通过 position: absolute 属性开启在父容器内的绝对定位,在通过 top && left && transform: translate(-50%, -50%) 属性控制子容器在父容器内的绝对居中位置

JavaScript 逻辑控制的核心代码

首先分解下,要实现 node 的移动需要哪些步骤和对应的 event 事件

  1. 子容器创建时,在父容器内的绝对位置
  2. 鼠标按键按下时,onmousedown 事件
  3. 鼠标移动时,onmousemove 事件
  4. 鼠标按键弹起时,onmouseup 事件

只要使用 onMousedown、onMousemove和onMouseup 这三个事件,就可以实现最简单的移动


/*
* 在子容器创建的时候获取子容器相对于父容器的 top 和 left 位置
*/

mounted () {
  this.left = this.$refs.fatherBox.offsetLeft
  this.top = this.$refs.fatherBox.offsetTop
}


/*
* 鼠标按下时
* 1. 开启允许子容器移动的 flag
* 2. 记录鼠标点击时的位置信息
*/

mouseDown (e) {
  // 设置允许弹窗移动的 flag
  this.moveFlag = true
  // 保存鼠标开始位置
  this.startLeft = e.clientX - this.left
  this.startTop = e.clientY - this.top
}


/*
* 鼠标移动时
* 1. 判断 flag 是否允许子容器移动
* 2. 设置弹框左边位置
* 3. 设置弹框右边位置
*/

move (e) {
  // 判断 flag 是否允许移动
  if (!this.moveFlag) return

  // 设置弹框左边位置
  this.left = e.clientX - this.startLeft
  // 设置弹框右边位置
  this.top = e.clientY - this.startTop

}

/*
* 鼠标按键弹起时
* 1. 关闭允许子容器移动的 flag
*/

mouseUp (e) {
  this.flag = false
}

通过这几个方法就可以获取鼠标按下移动时,鼠标的top 和 left 的偏移量,通过把这偏移量暴露出去给父组件,父组件实时设置子组件的 top 和 left 值,来使得子容器跟随鼠标的移动

父组件代码

父组件通过设置子组件的 ref、zIndex 值,而且父组件的 backValue 方法会从子组件接收 zIndex 值,通过 zIndex 来识别具体的子组件实例

父组件需要的 childList 属性也是被传递进来的,所以 close 方法必须要把子组件的 index 暴露出去进行 childList 的操作,当然你也可以使用 $parent 直接操作 childList 的数组,但是我强烈不建议这样操作

这里我采用的是 $EventBus 注册了一个事件,如果知道确定的数据流,和嵌套的结构,也可以直接在 props 里传递一个函数,然后用 close 方法调用传递进来的函数,把数据暴露出去


/*
* 父组件代码片段 jsx 版
*/

/**
 * @param childList required: true, type: Array, 子组件需要的数据列表
 * @param maxChild required: false, type: Number, default: 4, 允许最多多少个子组件同时存在
 */

export default {
  props: {
    childList: {
      type: Array,
      required: true
    },
    maxChild: {
      type: Number,
      default: 4
    }
  },
  data () {
    return {
    }
  },
  render () {
    return (
      <div class={'mvp-father-box'} ref={'father'}>
        {
          this.playList && this.childList.map((item, index) => {
            if (this.maxChild - 1 < index) return null
            return (
              <ChildComponents
                key={index}
                ref={index}
                zIndex={index}
                visible={item !== null}
                backValue={this.backValue}
                info={item !== null ? item : {}}
                close={this.close}
                width={600}
                height={400}
              />
            )
          })
        }
      </div>
    )
  },
  methods: {
    backValue (left, top, zIndex) {
      this.$refs[zIndex].$el.style.top = `${top}px`
      this.$refs[zIndex].$el.style.left = `${left}px`
    },
    close (index) {
      this.$EventBus.$emit('deletePlayerIndex', index)
    }
  }
}

设置子组件的围栏范围

当然,如果不设定围栏范围,子组件是会移出浏览器的可视范围的哦

设置移动围栏只要在 onmousemove 事件中进行判断 子组件的 top 和 left 是否超出设定的可视范围就可以了

完整的子组件代码如下


/*
* 子组件代码片段 jsx 版
*/

/**
 * @param visible required: true, type: Boolean 控制组件是否显示
 * @param info required: true, type: Object 接受的数据信息
 * @param close required: false, type: Function 关闭组件的回调
 * @param backValue required: true, type: Function(left: Number, top: Number, zIndex: Number) 回调函数,把弹框的移动距离传递给父组件
 * @param width required: false, type: String || Number, default value: 350px 子组件的宽度
 * @param height required: false, type: String || Number, default value: 270px 子组件的高度
 * @param zIndex required: false, type: Number, default value: 1 子组件的索引
 */

export default {
  props: {
    visible: {
      type: Boolean,
      default: false,
      required: true
    },
    info: {
      type: Object,
      required: true,
      default: () => {}
    },
    close: {
      type: Function,
      default: () => {}
    },
    backValue: {
      type: Function,
      required: true
    },
    width: {
      type: Number,
      default: 350
    },
    height: {
      type: Number,
      default: 270
    },
    zIndex: {
      type: Number,
      default: 1
    }
  },
  data () {
    return {
      moveFlag: false,
      startLeft: 0,
      startTop: 0,
      left: 0,
      top: 0,
      constLeft: 0,
      constTop: 0
    }
  },
  render () {
    if (!this.visible) return null
    return (
      <div
        class={'vpm-father-box'}
        onMousedown={(event) => this.mouseDown(event)}
        onMousemove={(event) => this.move(event)}
        onMouseout={(event) => this.mouseOut(event)}
        onMouseup={(event) => this.mouseUp(event)}
        ref={'fatherBox'}
        style={{ width: `${this.width}px`, height: `${this.height}px`, zIndex: 2000 }}
      >
        <div class={'vpm-title-box'}>
          <div class={'vpm-title'}>
            {this.info?.positionName ? this.info.positionName : '测试用例'}
          </div>
          <div class={'vpm-close-box'} onClick={(event) => this.closeModal(event)} />
        </div>
        <div>
        </div>
      </div>
    )
  },
  mounted () {
    this.constLeft = this.left = this.$refs.fatherBox.offsetLeft
    this.constTop = this.top = this.$refs.fatherBox.offsetTop
  },
  methods: {
    closeModal (e) {
      // 禁止事件冒泡
      e.stopPropagation()

      // 关闭的时候,把弹框的数据重置
      this.startLeft = 0
      this.startTop = 0
      this.left = this.constLeft
      this.top = this.constTop

      if (this.close) {
        this.close(this.zIndex)
      }
    },
    // 鼠标按下时
    mouseDown (e) {
      // 设置允许弹窗移动的 flag
      this.moveFlag = true
      // 保存鼠标开始位置
      this.startLeft = e.clientX - this.left
      this.startTop = e.clientY - this.top
    },
    // 鼠标在组件中移动时
    move (e) {
      // 判断 flag 是否允许移动
      if (!this.moveFlag) return

      // 判断回调函数是否存在
      if (!this.backValue) return

      // 判断是否超出左边视图
      if (this.$refs.fatherBox.offsetLeft < this.width / 2) {
        // 禁止弹框移动
        this.moveFlag = false
        // 设置弹框左边位置
        this.left = this.width / 2 + 10
        // 调用回调函数
        this.backValue(this.left, this.top, this.zIndex)
        return
      }

      // 判断是否超出右边视图
      if (this.$refs.fatherBox.offsetLeft > document.body.clientWidth - this.width / 2) {
        // 禁止弹框移动
        this.moveFlag = false
        // 设置弹框右边位置
        this.left = document.body.clientWidth - this.width / 2 - 10
        // 调用回调函数
        this.backValue(this.left, this.top, this.zIndex)
        return
      }

      // 判断是否超出顶部视图
      if (this.$refs.fatherBox.offsetTop < this.height / 2 + 70) {
        // 禁止弹框移动
        this.moveFlag = false
        // 设置弹框顶部位置
        this.top = this.height / 2 + 70 + 10
        // 调用回调函数
        this.backValue(this.left, this.top, this.zIndex)
        return
      }

      // 判断是否超出底部视图
      if (this.$refs.fatherBox.offsetTop > document.body.clientHeight - this.height / 2 - 50) {
        // 禁止弹框移动
        this.moveFlag = false
        // 设置弹框底部位置
        this.top = document.body.clientHeight - this.height / 2 - 50 - 10
        // 调用回调函数
        this.backValue(this.left, this.top, this.zIndex)
        return
      }

      // 设置弹框左边位置
      this.left = e.clientX - this.startLeft
      // 设置弹框右边位置
      this.top = e.clientY - this.startTop

      // 调用回调函数
      this.backValue(this.left, this.top, this.zIndex)
    },
    // 鼠标按键弹起时
    mouseOut (e) {
      // 禁止弹框移动
      this.moveFlag = false
    },
    // 鼠标移出组件时
    mouseUp (e) {
      // 禁止弹框移动
      this.moveFlag = false
    }
  }
}

操作传递进父组件的 childList

childList 是调用父组件时传递进来的一个数组,父组件通过这个数组来进行渲染子组件的个数,所以对这个数组的操作不正确,就会引发比较神奇的 bug


// 使用注册事件删除 数组中不需要的项

mounted () {
  this.$EventBus.$on('deletePlayerIndex', (index) => {
    this.playList.splice(index, 1, null)
  })
},

使用 null 来填充删除的项,而不是直接切片,直接切片会导致原数组的长度变化,而经过 vue 的渲染的时候,会导致 node 重新渲染,而且关闭的子元素并不是我们所期望的那一个

因为是用 null 来填充,所以添加新元素的时候,同样也要进行特殊处理

一共有三种情况:

  1. 当 childList 的长度为 0 时,直接使用 push 方法把项加入到最后
  2. 当 childList 的长度不为 0 且长度小于 4,同样直接使用 push 方法把项加入到最后
  3. 当 childList 的长度不为 0,循环数组,找到第一个为 null 的项,然后用新数据替换

// 添加项进 childList

switch (true) {
  case this.childList.length === 0:
    this.childList.push(item)
    break
  case this.childList.length !== 0:

    if (this.childList.length < 4) {
      this.childList.push(item)
      break
    }

    for (let i = 0; i < 3; i++) {
      if (this.childList[i] === null) {
        this.childList.splice(i, 1, item)
        break
      }
    }

    break
}