可拖拽 el-dialog 组件设计方案总结
1. 项目目标
实现一个可拖拽的 Element Plus Dialog 组件,支持:
- 对话框可通过头部区域拖拽移动
- 拖拽时不能将对话框拖出浏览器视口上边界
- 支持拖出视口左右和下边界
2. 技术选型决策
2.1 为什么不使用自定义指令?
el-dialog 使用 Teleport 机制,内容渲染到 body 中,自定义指令无法直接获取真实 DOM 元素。
2.2 为什么不使用原生 draggable 属性?
原生 draggable 属性功能有限,无法实现自定义边界限制需求。
3. 核心功能实现
3.1 拖拽区域设计
利用 el-dialog 的 header 区域作为拖拽把手:
const initDraggable = () => {
if (!dialogElement) return
// 获取 el-dialog 的 header 元素作为拖拽区域
const header = dialogElement.querySelector('.el-dialog__header')
if (header) {
header.style.cursor = 'move' // 设置鼠标样式
header.style.userSelect = 'none' // 禁止文本选择
header.addEventListener('mousedown', startDrag) // 添加拖拽事件
}
}
3.2 拖拽数据结构和事件处理
拖拽过程需要的数据和事件监听:
// 存储拖拽过程中的相关数据
let dragData = {
startX: 0, // 鼠标按下时的初始X坐标
startY: 0, // 鼠标按下时的初始Y坐标
oldTransformX: 0, // 开始拖拽前的X轴位移值
oldTransformY: 0 // 开始拖拽前的Y轴位移值
}
let isDragging = false // 标识是否正在拖拽
let dialogElement = null // 对话框的真实DOM元素
/**
* 开始拖拽
* @param {MouseEvent} e 鼠标事件对象
*/
const startDrag = (e) => {
if (!dialogElement) return
isDragging = true
// 获取元素当前的 transform 值
const transform = dialogElement.style.transform
const { x, y } = parseTranslate(transform)
// 记录拖拽起始状态
dragData.startX = e.clientX
dragData.startY = e.clientY
dragData.oldTransformX = x
dragData.oldTransformY = y
// 添加全局事件监听器
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
e.preventDefault() // 防止文本选中
}
/**
* 拖拽过程中更新位置
* @param {MouseEvent} e 鼠标事件对象
*/
const onDrag = (e) => {
if (!isDragging || !dialogElement) return
// 计算相对于起始点的移动距离
const deltaX = e.clientX - dragData.startX
const deltaY = e.clientY - dragData.startY
// 基于原始位置计算新的位置
const newTransformX = dragData.oldTransformX + deltaX
const newTransformY = dragData.oldTransformY + deltaY
// 应用新的位置变换
dialogElement.style.transform = `translate(${newTransformX}px, ${newTransformY}px)`
}
/**
* 停止拖拽
*/
const stopDrag = () => {
isDragging = false
// 移除全局事件监听器
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
3.3 Transform 解析
解析元素当前的 transform 值:
/**
* 解析 transform 字符串中的 translate 值
* @param {string} transform transform CSS 属性值
* @returns {Object} 包含 x 和 y 坐标的对象
*/
const parseTranslate = (transform) => {
// 如果没有 transform 或为 none,返回默认值
if (!transform || transform === 'none') {
return { x: 0, y: 0 }
}
// 使用正则表达式匹配 translate(x, y) 格式
const match = transform.match(/translate(\s*([+-]?\d*.?\d*)px\s*,\s*([+-]?\d*.?\d*)px\s*)/)
if (match) {
return {
x: parseFloat(match[1]) || 0, // 提取并转换 X 坐标
y: parseFloat(match[2]) || 0 // 提取并转换 Y 坐标
}
}
return { x: 0, y: 0 }
}
4. 边界限制功能
4.1 初始距离计算
计算对话框初始状态下距离顶部的最大可移动距离:
let maxMoveUpDistance = 0 // 记录最大向上移动距离
/**
* 对话框打开后的回调处理
*/
const onDialogOpened = async () => {
await nextTick() // 等待 DOM 更新完成
// 获取对话框的真实 DOM 元素
dialogElement = dialogRef.value.dialogContentRef.$el
if (dialogElement) {
// 获取元素相对于视口的位置信息
const rect = dialogElement.getBoundingClientRect()
// 初始距离顶部的距离即为最大可向上移动距离
maxMoveUpDistance = rect.top
// 禁用遮罩层的滚动条,防止弹窗拖拽出视口时出现不必要的滚动条
const overlay = dialogElement.parentElement
if (overlay) {
overlay.style.overflow = 'hidden'
}
}
// 初始化拖拽功能
initDraggable()
}
4.2 上边界限制
防止对话框拖出浏览器视口上边界:
/**
* 拖拽过程中更新位置(带边界限制)
* @param {MouseEvent} e 鼠标事件对象
*/
const onDrag = (e) => {
if (!isDragging || !dialogElement) return
// 计算相对于起始点的移动距离
const deltaX = e.clientX - dragData.startX
const deltaY = e.clientY - dragData.startY
// 基于原始位置计算新的位置
const newTransformX = dragData.oldTransformX + deltaX
const newTransformY = dragData.oldTransformY + deltaY
// 边界检查 - 只限制上边界
let finalY = newTransformY
// 当元素在上半区域时(Y坐标为负值)
if (newTransformY < 0) {
// 计算向上移动的距离(取绝对值)
const upwardDistance = Math.abs(newTransformY)
// 如果移动距离超过了允许的最大距离
if (upwardDistance > maxMoveUpDistance) {
// 限制在最大可移动范围内
finalY = -maxMoveUpDistance
}
}
// 应用新的位置变换
dialogElement.style.transform = `translate(${newTransformX}px, ${finalY}px)`
}
5. 完整示例代码
<!-- DraggableDialog.vue -->
<template>
<div>
<el-button type="primary" @click="openDialog">打开对话框</el-button>
<el-dialog
ref="dialogRef"
v-model="visible"
:before-close="handleBeforeClose"
@opened="onDialogOpened"
>
<template #header>
<slot name="header"></slot>
</template>
<slot></slot>
<template #footer>
<slot name="footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="confirmDialog">确定</el-button>
</slot>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {ref, nextTick, onUnmounted, computed} from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: '可拖拽对话框'
}
})
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 对外暴露打开和关闭方法
const openDialog = () => {
visible.value = true
}
const closeDialog = () => {
visible.value = false
emit('cancel')
}
const confirmDialog = () => {
emit('confirm')
closeDialog()
}
// 组件引用
const dialogRef = ref(null)
// 存储相关变量
let dialogHeaderRef = null
let isDragging = false
let dialogElement = null
let maxMoveUpDistance = 0 // 记录最大向上移动距离
// 存储拖拽过程中的相关数据
let dragData = {
startX: 0, // 鼠标按下时的初始X坐标
startY: 0, // 鼠标按下时的初始Y坐标
oldTransformX: 0, // 开始拖拽前的X轴位移值
oldTransformY: 0 // 开始拖拽前的Y轴位移值
}
/**
* 关闭对话框前的处理
*/
const handleBeforeClose = (done) => {
// 重置位置
if (dialogElement) {
dialogElement.style.transform = ''
}
done()
}
/**
* 对话框打开后的回调处理
*/
const onDialogOpened = async () => {
await nextTick() // 等待 DOM 更新完成
// 获取对话框的真实 DOM 元素
dialogElement = dialogRef.value.dialogContentRef.$el
// 计算初始距离顶部的距离
if (dialogElement) {
const rect = dialogElement.getBoundingClientRect()
maxMoveUpDistance = rect.top // 初始距离顶部的距离
// 禁用遮罩层的滚动条
const overlay = dialogElement.parentElement
if (overlay) {
overlay.style.overflow = 'hidden'
}
}
// 初始化拖拽功能
initDraggable()
}
/**
* 初始化拖拽功能
*/
const initDraggable = () => {
if (!dialogElement) return
// 获取 el-dialog 的 header 元素作为拖拽区域
const header = dialogElement.querySelector('.el-dialog__header')
if (header) {
dialogHeaderRef = header
header.style.cursor = 'move' // 设置拖拽时光标样式
header.style.userSelect = 'none' // 禁止拖拽时选中文本
header.addEventListener('mousedown', startDrag) // 添加拖拽事件
}
}
/**
* 解析 transform 字符串中的 translate 值
* @param {string} transform transform CSS 属性值
* @returns {Object} 包含 x 和 y 坐标的对象
*/
const parseTranslate = (transform) => {
// 如果没有 transform 或为 none,返回默认值
if (!transform || transform === 'none') {
return { x: 0, y: 0 }
}
// 使用正则表达式匹配 translate(x, y) 格式
const match = transform.match(/translate(\s*([+-]?\d*.?\d*)px\s*,\s*([+-]?\d*.?\d*)px\s*)/)
if (match) {
return {
x: parseFloat(match[1]) || 0, // 提取并转换 X 坐标
y: parseFloat(match[2]) || 0 // 提取并转换 Y 坐标
}
}
return { x: 0, y: 0 }
}
/**
* 开始拖拽
* @param {MouseEvent} e 鼠标事件对象
*/
const startDrag = (e) => {
if (!dialogElement) return
isDragging = true
// 获取元素当前的 transform 值
const transform = dialogElement.style.transform
const { x, y } = parseTranslate(transform)
// 记录拖拽起始状态
dragData.startX = e.clientX
dragData.startY = e.clientY
dragData.oldTransformX = x
dragData.oldTransformY = y
// 添加全局事件监听器
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
e.preventDefault() // 防止文本选中
}
/**
* 拖拽过程中更新位置(带边界限制)
* @param {MouseEvent} e 鼠标事件对象
*/
const onDrag = (e) => {
if (!isDragging || !dialogElement) return
// 计算相对于起始点的移动距离
const deltaX = e.clientX - dragData.startX
const deltaY = e.clientY - dragData.startY
// 基于原始位置计算新的位置
const newTransformX = dragData.oldTransformX + deltaX
const newTransformY = dragData.oldTransformY + deltaY
// 边界检查 - 只限制上边界
let finalY = newTransformY
// 当元素在上半区域时(Y坐标为负值)
if (newTransformY < 0) {
// 计算向上移动的距离(取绝对值)
const upwardDistance = Math.abs(newTransformY)
// 如果移动距离超过了允许的最大距离
if (upwardDistance > maxMoveUpDistance) {
// 限制在最大可移动范围内
finalY = -maxMoveUpDistance
}
}
// 应用新的位置变换
dialogElement.style.transform = `translate(${newTransformX}px, ${finalY}px)`
}
/**
* 停止拖拽
*/
const stopDrag = () => {
isDragging = false
// 移除全局事件监听器
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// 组件销毁时清理事件监听器
onUnmounted(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
})
// 向外暴露方法
defineExpose({
openDialog,
closeDialog
})
</script>