Bootstrap

开发适合el-dialog的拉伸拖拽自定义指令和适配自定义的图片查看组件

目录

一、应用场景

二、开发流程

1.自定义指令

2.功能原理

3.难点

三、详细开发

四、总结


一、应用场景

我之前有开发过一个图片查看的组件,这个组件可在单页面打开,也可以在弹窗里打开,但是弹窗因为是比较固定,所以有一些局限性,只能拖拽,不能改变弹窗大小,于是有了开发【可以拖拽改变大小的弹窗】组件

原先的图片查看组件的博客地址:仿照elemenet-image的预览开发图片切换和放大缩小等功能_vue3 <el-image> 下方一行缩略图 可左右-CSDN博客

上方的组件实现效果如此:

  1. 目标:这次我需要实现的是满足上方的图片查看的功能(去掉底部的轮播图,弹窗不太需要),还需要满足弹窗的拖拽边框可以改变弹窗大小,并且弹窗的顶部可以被拖拽着移动位置。
  2. 实现方案:因为我是使用的是el-dialog,所以本身弹窗就可以拖拽,只是不能被手动改变大小,查找了一些解决方案后,于是借助一些思想,实现了一个自定义指令,期间踩了一些坑。
  3. 实现功能:
  • 拖动弹窗:通过拖动弹窗的头部 (.el-dialog__header),可以在页面上自由移动弹窗的位置。鼠标按下头部并拖动时,会实时更新弹窗的位置。但是不能移出左侧和上侧视图范围,这部分也在下面的调整大小里有限制。
  • 双击全屏/还原:双击弹窗的头部,可以在全屏和恢复到之前的大小和位置之间切换。全屏状态下,弹窗覆盖整个视口,头部不可拖动。再次双击会恢复到初始的大小和位置。

  • 调整大小

    • 右下角调整:通过右下角的一个小区域 (se-resize),可以拖动调整弹窗的宽度和高度,同时保持最小宽度和高度限制。
    • 左右侧调整:通过左右两侧的小区域 (w-resize),可以水平调整弹窗的宽度。同样,宽度不能小于设定的最小值。
    • 下侧调整:通过下边的小区域 (n-resize),可以垂直调整弹窗的高度,高度不能小于设定的最小值

4.实现效果:


二、开发流程

这里对创建自定义指令做一些简单介绍

1.自定义指令

首先需要了解以下知识:

Vue 3 的自定义指令提供了一些生命周期钩子,用于在指令应用到元素的不同阶段执行特定的操作:

  • beforeMount:指令绑定到元素并插入父节点之前调用。
  • mounted:指令绑定到元素后调用。
  • beforeUpdate:指令所在组件的 VNode 更新之前调用。
  • updated:指令所在组件的 VNode 更新之后调用。
  • beforeUnmount:指令所在组件销毁之前调用。
  • unmounted:指令绑定的元素移出 DOM 之后调用。

简单的示例:
 

// 在 main.js 中注册全局自定义指令
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});

app.mount('#app');
 

使用:
<template> <input v-focus /> </template>

我在开发过程中反复测试,得出我们在updated进行绑定就行,这样才能保证弹窗的创建和绑定。

如果需要实现一个可以拖拽改变弹窗大小的指令,那么首先建立一个文件夹,如下:

这里先不讨论dialog.js的具体内容,先创建如下的内容:

// src\directive\index.js
import drag from './dialog'
export default function (app) {
  app.directive('dialogDrag', drag)
}


// src\directive\dialog.js

export const dialogDrag = (el, binding, vnode, oldVnode) => {

//这里是补充逻辑的地方
}


export default {
  updated(el, binding, vnode, prevVnode) {
    dialogDrag(el, binding, vnode, prevVnode)
  }
}

2.功能原理

为了实现我想要的功能,可以通过 JavaScript 操作 DOM 元素的样式和事件监听器,来实现拖拽拉伸移动等等,开发前,先进行三项功能的原理整理:

 1. 弹窗拖拽功能——通过拖动弹窗的标题栏来移动整个弹窗的位置

  • 通过 el.querySelector('.el-dialog__header') 获取弹窗的标题栏元素(.el-dialog__header)。
  • 设置标题栏的 cursormove,提示用户可以拖动该区域。
  • 通过 mousedown 事件监听用户按下鼠标的动作,计算并记录鼠标点击位置与弹窗左上角的偏移量。
  • 当鼠标移动时,通过 mousemove 事件更新弹窗的位置,使其跟随鼠标移动。在 mouseup 事件中移除 mousemovemouseup 事件监听,以终止拖拽操作。

2. 弹窗拉伸功能——通过拖动弹窗的边缘或角落来调整弹窗的尺寸。

  • 在弹窗的右下角(se-resize)、右边(w-resize)、左边(w-resize)、下边(n-resize)分别添加拉伸控制块,这些控制块是通过 document.createElement('div') 动态创建并插入到弹窗中。
  • 每个控制块绑定一个 mousedown 事件,用于监听用户的拉伸操作。根据用户鼠标移动的方向,计算弹窗的宽度或高度变化,并更新弹窗的 widthheight 样式属性。
  • 拉伸结束时,通过 mouseup 事件移除 mousemovemouseup 事件监听,停止尺寸调整操作。

3. 双击全屏与还原——通过双击弹窗的标题栏实现弹窗全屏和还原

  • 双击标题栏时(dblclick 事件),根据当前弹窗是否全屏状态(由 isFullScreen 标志控制)执行全屏或还原操作。
  • 全屏时,将弹窗的位置和尺寸调整为占满整个视窗(100VW, 100VH),并移除标题栏的拖拽功能。
  • 还原时,恢复弹窗到全屏前的尺寸和位置,并重新启用标题栏的拖拽功能。

3.难点

  1. 同步尺寸和位置:在拖拽或拉伸时,需要实时同步弹窗的位置和尺寸,这涉及到对鼠标移动的精确跟踪,并处理弹窗在不同浏览器窗口尺寸下的表现。
  2. 边界处理:在拖拽和拉伸时,防止弹窗超出窗口的可视区域,尤其是避免标题栏被拖出窗口顶部。
  3. 多方向拉伸的冲突处理:在实现多方向拉伸时,确保各方向的拉伸控制块不会互相冲突。例如,右下角的拉伸控制块涉及同时调整宽度和高度,需要正确处理与单方向拉伸控制块之间的优先级问题。

三、详细开发

注意!我先写踩坑的点,如下:

第一步,我们要找到我们需要在哪里使用,我的应用场景就是在弹窗的地方使用,所以我就想定义一个弹窗的指令,理想的情况是这样的:

  <el-dialog
      v-model="dialogVisible"
      v-dialogDrag  ///这里
      width="50%"
      top="0vh"
      :z-index="2080"
      :modal="false"
      :close-on-click-modal="false"
      modal-class="dialog_class"
    >
      <div class="image-view-container">
        <ImageView :url="dialogImageUrl" style="width: 100%" @changeImage="changeImage" />
      </div>
    </el-dialog>

然后在指令里去写获取当前弹窗的DOM,比如这样:

export const dialogDrag = (el, binding, vnode, oldVnode) => {
  const dialogElement = el.querySelector('.el-dialog')
  // console.log(dialogElement) // 这里是 el-dialog 元素的 DOM
  if (!dialogElement) {
    return
  }
}

就会发现怎么也获取不到当前的dom。 

我一开始以为我是钩子时机不对,updated 钩子可能会在元素还未完全渲染时触发,这可能导致无法获取到子元素。所以为了确保 DOM 结构已经完全渲染,尝试使用 mounted 钩子,结果也一样,然后我尝试用我常用的方法:nextTick,也无法实现……于是第一步就卡在这里了。

问题就在于:el-dialog 组件可能还未完全渲染完成,无法正确获取到 DOM 元素。

当然可以通过添加一些调试信息,检查 el-dialog 是否确实存在,我在写的过程中,确实这样写无法实现。

出现这样的问题:

所以经过多次调试,我选择了这样的方式:

<div v-dialogDrag class="image-view">
    <el-dialog
      v-model="dialogVisible"
      width="50%"
      top="0vh"
      :z-index="2080"
      :modal="false"
      :close-on-click-modal="false"
      modal-class="dialog_class"
    >
      <div class="image-view-container">
        <ImageView :url="dialogImageUrl" style="width: 100%" @changeImage="changeImage" />
      </div>
    </el-dialog>
  </div>

那么具体的指令的代码如下:

export const dialogDrag = (el, binding, vnode, oldVnode) => {
  const dialogElement = el.querySelector('.el-dialog')
  // console.log(dialogElement) // 这里是 el-dialog 元素的 DOM
  if (!dialogElement) {
    return
  }
  //弹框可拉伸最小宽高
  let minWidth = 400
  let minHeight = 400
  //初始非全屏
  let isFullScreen = false
  //当前宽高
  let nowWidth = 0
  let nowHight = 0
  //当前顶部高度
  let nowMarginTop = 0
  //获取弹框头部(这部分可双击全屏)
  const dialogHeaderEl = el.querySelector('.el-dialog__header')
  //弹窗
  const dragDom = el.querySelector('.el-dialog')
  //弹窗body
  const dialogBodyEl = el.querySelector('.el-dialog__body')
  // 设置body的最小高宽
  dialogBodyEl.style.minWidth = minWidth - 5 + 'px'
  dialogBodyEl.style.minHeight = minHeight - 100 + 'px'
  dialogBodyEl.style.height = '100%'
  //给弹窗加上overflow auto;不然缩小时框内的标签可能超出dialog;
  dragDom.style.overflow = 'auto'
  //清除选择头部文字效果
  dialogHeaderEl.onselectstart = new Function('return false')
  //头部加上可拖动cursor
  dialogHeaderEl.style.cursor = 'move'

  // 获取原有属性 ie dom元素.currentStyle 火狐谷歌 window.getComputedStyle(dom元素, null);
  const sty = dragDom.currentStyle || window.getComputedStyle(dragDom, null)

  let moveDown = (e) => {
    // 鼠标按下,计算当前元素距离可视区的距离
    const disX = e.clientX - dialogHeaderEl.offsetLeft
    const disY = e.clientY - dialogHeaderEl.offsetTop

    // 计算弹窗样式中的 --el-dialog-margin-top 值
    const dialogStyles = window.getComputedStyle(dragDom)
    const marginTopVh = parseFloat(dialogStyles.getPropertyValue('--el-dialog-margin-top'))

    // 计算初始弹窗顶部相对于可视区域顶部的偏移量
    const dialogMarginTopPx = window.innerHeight * (marginTopVh / 100)
    const initialTop = dialogMarginTopPx

    // 获取初始弹窗距离窗口左侧的距离
    const dialogMarginLeft = getComputedStyle(dragDom).marginLeft
    const initialLeft = parseFloat(dialogMarginLeft)

    // 获取到的值带px 正则匹配替换
    let styL, styT

    // 注意在ie中 第一次获取到的值为组件自带50% 移动之后赋值为px
    if (sty.left.includes('%')) {
      styL = +document.body.clientWidth * (+sty.left.replace(/%/g, '') / 100)
      styT = +document.body.clientHeight * (+sty.top.replace(/%/g, '') / 100)
    } else {
      styL = +sty.left.replace(/px/g, '')
      styT = +sty.top.replace(/px/g, '')
    }

    document.onmousemove = function (e) {
      // 通过事件委托,计算移动的距离
      const l = e.clientX - disX
      const t = e.clientY - disY
      // 计算弹窗的左边界,不能超过窗口的左侧
      const minLeft = -initialLeft

      // 控制弹窗的左边界
      const left = Math.max(minLeft, l + styL)
      // 移动当前元素
      dragDom.style.left = `${left}px`
      dragDom.style.top = `${Math.max(-initialTop, t + styT)}px` //确保了拖拽过程中弹窗头部不会超出窗口的顶部

      //将此时的位置传出去
      //binding.value({x:e.pageX,y:e.pageY})
    }

    document.onmouseup = function (e) {
      document.onmousemove = null
      document.onmouseup = null
    }
  }
  dialogHeaderEl.onmousedown = moveDown
  //双击(头部)效果不想要可以注释
  dialogHeaderEl.ondblclick = (e) => {
    if (isFullScreen == false) {
      nowHight = dragDom.clientHeight
      nowWidth = dragDom.clientWidth
      nowMarginTop = dragDom.style.marginTop
      dragDom.style.left = 0
      dragDom.style.top = 0
      dragDom.style.height = '100VH'
      dragDom.style.width = '100VW'
      dragDom.style.marginTop = 0
      dragDom.style.marginBottom = 0
      isFullScreen = true
      dialogHeaderEl.style.cursor = 'initial'
      dialogHeaderEl.onmousedown = null
    } else {
      dragDom.style.height = 'auto'
      dragDom.style.width = nowWidth + 'px'
      dragDom.style.marginTop = nowMarginTop
      isFullScreen = false
      dialogHeaderEl.style.cursor = 'move'
      dialogHeaderEl.onmousedown = moveDown
    }
  }

  //拉伸右下方
  let resizeEl = document.createElement('div')
  dragDom.appendChild(resizeEl)
  //在弹窗右下角加上一个10-10px的控制块
  resizeEl.style.cursor = 'se-resize'
  resizeEl.style.position = 'absolute'
  resizeEl.style.height = '10px'
  resizeEl.style.width = '10px'
  resizeEl.style.right = '0px'
  resizeEl.style.bottom = '0px'
  resizeEl.style.zIndex = '99'
  //鼠标拉伸弹窗
  resizeEl.onmousedown = (e) => {
    // 记录初始x位置
    let startX = e.clientX
    // 鼠标按下,计算当前元素距离可视区的距离
    let disX = e.clientX - resizeEl.offsetLeft
    let disY = e.clientY - resizeEl.offsetTop
    document.onmousemove = function (e) {
      e.preventDefault() // 移动时禁用默认事件
      // 通过事件委托,计算移动的距离
      //这里 由于elementUI的dialog控制居中的,所以水平拉伸效果是双倍
      //比较最小宽高和现在的宽高的大小,取大值
      dragDom.style.width = `${Math.max(minWidth, e.clientX - disX + (e.clientX - startX))}px`
      dragDom.style.height = `${Math.max(minHeight, e.clientY - disY)}px`
    }
    //拉伸结束
    document.onmouseup = function (e) {
      document.onmousemove = null
      document.onmouseup = null
    }
  }

  //拉伸右边
  let resizeElR = document.createElement('div')
  dragDom.appendChild(resizeElR)
  //在弹窗右下角加上一个10-10px的控制块
  resizeElR.style.cursor = 'w-resize'
  resizeElR.style.position = 'absolute'
  resizeElR.style.height = '100%'
  resizeElR.style.width = '10px'
  resizeElR.style.right = '0px'
  resizeElR.style.top = '0px'
  //鼠标拉伸弹窗
  resizeElR.onmousedown = (e) => {
    let elW = dragDom.clientWidth
    let initialOffsetLeft = dragDom.offsetLeft
    // 记录初始x位置
    let startX = e.clientX
    document.onmousemove = function (e) {
      e.preventDefault() // 移动时禁用默认事件
      //右侧鼠标拖拽位置
      if (startX > initialOffsetLeft + elW - 20 && startX < initialOffsetLeft + elW) {
        //往左拖拽
        if (startX > e.clientX) {
          dragDom.style.width = `${Math.max(minWidth, elW - (startX - e.clientX) * 2)}px`
        }
        //往右拖拽
        if (startX < e.clientX) {
          dragDom.style.width = `${elW + (e.clientX - startX) * 2}px`
        }
      }
    }
    //拉伸结束
    document.onmouseup = function (e) {
      document.onmousemove = null
      document.onmouseup = null
    }
  }

  //拉伸左边
  let resizeElL = document.createElement('div')
  dragDom.appendChild(resizeElL)
  //在弹窗右下角加上一个10-10px的控制块
  resizeElL.style.cursor = 'w-resize'
  resizeElL.style.position = 'absolute'
  resizeElL.style.height = '100%'
  resizeElL.style.width = '10px'
  resizeElL.style.left = '0px'
  resizeElL.style.top = '0px'
  //鼠标拉伸弹窗
  resizeElL.onmousedown = (e) => {
    let elW = dragDom.clientWidth
    let initialOffsetLeft = dragDom.offsetLeft
    // 记录初始x位置
    let startX = e.clientX
    document.onmousemove = function (e) {
      e.preventDefault() // 移动时禁用默认事件
      //左侧鼠标拖拽位置
      if (startX > initialOffsetLeft && startX < initialOffsetLeft + 20) {
        //往左拖拽
        if (startX > e.clientX) {
          dragDom.style.width = `${elW + (startX - e.clientX) * 2}px`
        }
        //往右拖拽
        if (startX < e.clientX) {
          dragDom.style.width = `${Math.max(minWidth, elW - (e.clientX - startX) * 2)}px`
        }
      }
    }
    //拉伸结束
    document.onmouseup = function (e) {
      document.onmousemove = null
      document.onmouseup = null
    }
  }

  // 拉伸下边
  let resizeElB = document.createElement('div')
  dragDom.appendChild(resizeElB)
  //在弹窗右下角加上一个10-10px的控制块
  resizeElB.style.cursor = 'n-resize'
  resizeElB.style.position = 'absolute'
  resizeElB.style.height = '10px'
  resizeElB.style.width = '100%'
  resizeElB.style.left = '0px'
  resizeElB.style.bottom = '0px'
  // 鼠标拉伸弹窗
  resizeElB.onmousedown = (e) => {
    // 记录初始鼠标位置和弹窗尺寸
    let startY = e.clientY
    let elH = dragDom.clientHeight

    document.onmousemove = function (e) {
      e.preventDefault() // 移动时禁用默认事件
      dragDom.style.height = `${Math.max(minHeight, elH + (e.clientY - startY) * 2)}px`
    }

    // 拉伸结束
    document.onmouseup = function (e) {
      document.onmousemove = null
      document.onmouseup = null
    }
  }
}

export default {
  updated(el, binding, vnode, prevVnode) {
    dialogDrag(el, binding, vnode, prevVnode)
  }
}

 mousemovemouseupmousedown 是 JavaScript 中用于处理鼠标交互的事件,分别对应鼠标的移动、按下和松开操作,所以上述代码的实现也是注意借助这几个事件来实现的。当然加一些防抖,效果会更好。


四、总结

说下难点,第一个就是生命周期的选择和指令使用的位置,一定套一个div。

其他难点就是,需要动态计算弹窗的位置与尺寸,因为弹窗的位置和尺寸是动态计算的,涉及到鼠标的实时位置和弹窗初始位置之间的关系。为了确保用户体验,处理窗口边界的限制也是一个难点,确保弹窗不会拖出可视区域(这里我的可视区域是左边和上面不能拖出,但是右边和下边可以),还有一个比较难的就是弹窗内的图片查看组件的样式适配,因为要对弹窗边框拖拽改变大小时,图片也要自适应的改变,所以这个样式方面就做了很多功夫,代码也贴上去了,仅供参考~

至于可以优化的点,应该就是拖拽边框的时候更丝滑和防抖吧,如果有其他建议,麻烦评论区指出~

;