目录
一、应用场景
我之前有开发过一个图片查看的组件,这个组件可在单页面打开,也可以在弹窗里打开,但是弹窗因为是比较固定,所以有一些局限性,只能拖拽,不能改变弹窗大小,于是有了开发【可以拖拽改变大小的弹窗】组件
原先的图片查看组件的博客地址:仿照elemenet-image的预览开发图片切换和放大缩小等功能_vue3 <el-image> 下方一行缩略图 可左右-CSDN博客
上方的组件实现效果如此:
- 目标:这次我需要实现的是满足上方的图片查看的功能(去掉底部的轮播图,弹窗不太需要),还需要满足弹窗的拖拽边框可以改变弹窗大小,并且弹窗的顶部可以被拖拽着移动位置。
- 实现方案:因为我是使用的是el-dialog,所以本身弹窗就可以拖拽,只是不能被手动改变大小,查找了一些解决方案后,于是借助一些思想,实现了一个自定义指令,期间踩了一些坑。
- 实现功能:
- 拖动弹窗:通过拖动弹窗的头部 (
.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
)。- 设置标题栏的
cursor
为move
,提示用户可以拖动该区域。- 通过
mousedown
事件监听用户按下鼠标的动作,计算并记录鼠标点击位置与弹窗左上角的偏移量。- 当鼠标移动时,通过
mousemove
事件更新弹窗的位置,使其跟随鼠标移动。在mouseup
事件中移除mousemove
和mouseup
事件监听,以终止拖拽操作。2. 弹窗拉伸功能——通过拖动弹窗的边缘或角落来调整弹窗的尺寸。
- 在弹窗的右下角(
se-resize
)、右边(w-resize
)、左边(w-resize
)、下边(n-resize
)分别添加拉伸控制块,这些控制块是通过document.createElement('div')
动态创建并插入到弹窗中。- 每个控制块绑定一个
mousedown
事件,用于监听用户的拉伸操作。根据用户鼠标移动的方向,计算弹窗的宽度或高度变化,并更新弹窗的width
和height
样式属性。- 拉伸结束时,通过
mouseup
事件移除mousemove
和mouseup
事件监听,停止尺寸调整操作。3. 双击全屏与还原——通过双击弹窗的标题栏实现弹窗全屏和还原
- 双击标题栏时(
dblclick
事件),根据当前弹窗是否全屏状态(由isFullScreen
标志控制)执行全屏或还原操作。- 全屏时,将弹窗的位置和尺寸调整为占满整个视窗(
100VW
,100VH
),并移除标题栏的拖拽功能。- 还原时,恢复弹窗到全屏前的尺寸和位置,并重新启用标题栏的拖拽功能。
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)
}
}
mousemove
、mouseup
和mousedown
是 JavaScript 中用于处理鼠标交互的事件,分别对应鼠标的移动、按下和松开操作,所以上述代码的实现也是注意借助这几个事件来实现的。当然加一些防抖,效果会更好。
四、总结
说下难点,第一个就是生命周期的选择和指令使用的位置,一定套一个div。
其他难点就是,需要动态计算弹窗的位置与尺寸,因为弹窗的位置和尺寸是动态计算的,涉及到鼠标的实时位置和弹窗初始位置之间的关系。为了确保用户体验,处理窗口边界的限制也是一个难点,确保弹窗不会拖出可视区域(这里我的可视区域是左边和上面不能拖出,但是右边和下边可以),还有一个比较难的就是弹窗内的图片查看组件的样式适配,因为要对弹窗边框拖拽改变大小时,图片也要自适应的改变,所以这个样式方面就做了很多功夫,代码也贴上去了,仅供参考~
至于可以优化的点,应该就是拖拽边框的时候更丝滑和防抖吧,如果有其他建议,麻烦评论区指出~