如何实现弹窗的 双击关闭 & 拖动 & 图层优先级

我为弹窗设置了以下功能:

  1. 双击弹窗任意区域,即可关闭弹窗;
  2. 点击键盘的esc按键也可以关闭弹窗;
  3. 拖拽弹窗顶部区域可以拖动弹窗移动位置;
  4. 打开多个弹窗,可以保证每次最新打开的弹窗可以位于最上层,如果我点击置于后面的弹窗也可以将其优先级置于最上面。

如何实现以上功能?

1.双击关闭

html 复制代码
<div @dblclick="handleDoubleClick">
    弹窗1
</div>

为弹窗绑定 dbclick方法,表明双击弹窗之后会触发的方法:

javascript 复制代码
const handleDoubleClick = (e) => {
  closeDialog();
};

2.ESC关闭

javascript 复制代码
onMounted(() => {
  // 监听键盘 ESC
  const handleKeyDown = (e) => {
    if (e.key === 'Escape' || e.key === 'Esc') {
      closeDialog();
    }
  };
  window.addEventListener('keydown', handleKeyDown);

  // 在卸载前移除监听
  onBeforeUnmount(() => {
    window.removeEventListener('keydown', handleKeyDown);
  });
});

3.设置弹窗优先级

html 复制代码
<div :style="{ zIndex: computedZIndex }">
    弹窗1
</div>

我们为弹窗绑定动态 style 来实时更新它的层级。

这里我引入了状态管理库 pinia,因此下面我会使用全局的变量。
下面我定义了一个全局变量 maxZIndex,用于更新此时页面中优先级的最大值,并为最后一次操作的弹窗更新优先级的值。dialogList 为管理当前弹出的弹窗。

javascript 复制代码
import { defineStore } from 'pinia'

export const useHotStore = defineStore('hot', {
    state: () => {
        return {
            maxZIndex: 1000,
            dialogList: [],
        }
    },
    actions: {
        getNewMaxZIndex() {
            this.maxZIndex += 1
            return this.maxZIndex
        },
        registerDialog(id, closeFn) { // 注册弹窗:挂载时调用,记录弹窗信息
            // 找到当前弹窗的 zIndex(此时已通过 getNewMaxZIndex 赋值)
            const zIndex = this.maxZIndex
            this.dialogList.push({ id, zIndex, closeFn })
        },
        unregisterDialog(id) { // 注销弹窗:卸载时调用,移除弹窗信息
            this.dialogList = this.dialogList.filter(dialog => dialog.id !== id)
        },
        closeTopDialog() { // 关闭最顶层弹窗:按 zIndex 倒序排序,取第一个关闭
            if (this.dialogList.length === 0) return
            // 按层级从高到低排序,取最顶层弹窗
            const sorted = [...this.dialogList].sort((a, b) => b.zIndex - a.zIndex)
            const topDialog = sorted[0]
            // 执行弹窗的关闭方法
            topDialog.closeFn()
        }
    },
})

closeTopDialog() 方法是要关闭最顶层的弹窗,也就是当我们按键盘上的 esc 时可以逐级关闭各个弹窗。

javascript 复制代码
import { useHotStore } from '@/store/index.js'

const hotStore = useHotStore()
const computedZIndex = ref(hotStore.maxZIndex)

// 关键:每个弹窗都需要定义一个唯一的id作为标识
const dialogId = `hot-point-detail-${Date.now()}`

onMounted(() => { // closeDialog方法是我控制弹窗关闭的方法,这里不描述
  computedZIndex.value = hotStore.getNewMaxZIndex()
  hotStore.registerDialog(dialogId, closeDialog)
})

onBeforeUnmount(() => {
  hotStore.unregisterDialog(dialogId)
})

4.拖动弹窗

html 复制代码
<div class="dialog-drag-container" :style="{ zIndex: computedZIndex }" @dblclick="handleDoubleClick">
    <img class="close-dialog-btn no-drag" src="close-btn.png" @click="closeDialog"/>
    <div style="cursor: move;" v-drag>弹窗1</div>
</div>

我们为弹窗绑定了类名 dialog-drag-container,这是一个全局类名,之后我们无论定义几个弹窗,都需要绑定这个类名。

在内部我们有弹窗的主体区域,也有关闭按钮:

  • 如果你想可以拖动这个弹窗的任意位置都可以改变位置,就在最外层的div上绑定 v-drag 指令。
  • 如果你想在弹窗顶部区域去控制弹窗关闭,就在顶部定义一个10px高度的div,并为它绑定上 v-drag 指令。如果不想影响到关闭按钮,可以为这个标签绑定 no-drag 类名。
  • 上面提到的 v-drag 指令是自己编写的我们需要定义一个 drag.js 用于控制全局弹窗的拖动事件:
javascript 复制代码
import { useHotStore } from '@/page/board/store/hot'

// 注册拖动指令:v-drag,绑定到弹窗的「可拖动区域」(如标题栏)
export const dragDirective = {
    mounted(el, binding) {
        const hotStore = useHotStore()
        const dialogEl = el.closest('.dialog-drag-container') // 弹窗最外层容器(需所有弹窗统一类名)
        if (!dialogEl) return console.warn('未找到弹窗容器,请给弹窗添加.dialog-container类')

        let isDragging = false // 是否正在拖动
        let startX = 0 // 鼠标按下时的X坐标
        let startY = 0 // 鼠标按下时的Y坐标
        let dialogLeft = 0 // 弹窗初始left值
        let dialogTop = 0 // 弹窗初始top值

        // 1. 鼠标按下:记录初始位置和状态
        el.addEventListener('mousedown', (e) => {
            // 仅左键拖动(排除右键/中键)
            if (e.button !== 0) return

            isDragging = true
            // 记录鼠标按下时的坐标(相对于文档)
            startX = e.clientX
            startY = e.clientY
            // 记录弹窗初始位置(若弹窗用fixed定位,直接取offsetLeft/offsetTop)
            const dialogStyle = window.getComputedStyle(dialogEl)
            dialogLeft = parseInt(dialogStyle.left) || 0
            dialogTop = parseInt(dialogStyle.top) || 0

            // 拖动开始:将当前弹窗层级设为最高
            dialogEl.style.zIndex = hotStore.getNewMaxZIndex()

            // 给文档绑定mousemove和mouseup(避免鼠标移出拖动区域后失效)
            document.addEventListener('mousemove', handleMouseMove)
            document.addEventListener('mouseup', handleMouseUp)
        })

        // 2. 鼠标移动:计算弹窗新位置并更新
        const handleMouseMove = (e) => {
            if (!isDragging) return

            // 计算鼠标移动的距离
            const moveX = e.clientX - startX
            const moveY = e.clientY - startY
            // 计算弹窗新位置(避免移出视口,可选)
            const newLeft = dialogLeft + moveX
            const newTop = dialogTop + moveY

            // 更新弹窗位置(弹窗需设置position: fixed/fixed)
            dialogEl.style.left = `${newLeft}px`
            dialogEl.style.top = `${newTop}px`
        }

        // 3. 鼠标松开:结束拖动,移除事件监听
        const handleMouseUp = () => {
            isDragging = false
            document.removeEventListener('mousemove', handleMouseMove)
            document.removeEventListener('mouseup', handleMouseUp)
        }

        // 组件卸载时清除事件(避免内存泄漏)
        el._unmountDrag = () => {
            el.removeEventListener('mousedown', el._mousedownHandler)
            document.removeEventListener('mousemove', handleMouseMove)
            document.removeEventListener('mouseup', handleMouseUp)
        }
    },

    unmounted(el) {
        // 组件卸载时清除事件
        if (el._unmountDrag) el._unmountDrag()
    }
}

// 在Vue中全局注册指令(main.js中引入)
export const registerDragDirective = (app) => {
    app.directive('drag', dragDirective)
}

在拖动方法中也引用到了改变弹窗优先级的方法,这就做到了两者联动。

5.内部事件冒泡处理

我在弹窗中放了一个 video 播放器,这个播放器可以双击全屏,或者我连续点击两次"播放/暂停"按钮,就会导致事件冒泡,从而触发关闭弹窗的方法,应该如何处理?

html 复制代码
<video ref="dialogVideoEl" class="dialog-video" playsinline preload="auto" controls @dblclick.stop @click.stop></video>

这里有三个关键点:

  1. 为标签绑定 @click.stop 和 @dbclick.stop 来阻止冒泡
  2. 必须是闭合的video标签,即 <video></video>,不能是单闭合
  3. 在最外层容器绑定的 @dblclick="handleDoubleClick" 事件中,增加如下处理:
javascript 复制代码
const handleDoubleClick = (e) => {
  if (e) {
    const path = e.composedPath ? e.composedPath() : (e.path || []);
    const foundVideoOrPlyr = path.some(node => {
      try {
        return node && (node.tagName === 'VIDEO' || (node.classList && node.classList.contains && node.classList.contains('plyr')));
      } catch {
        return false;
      }
    });

    if (foundVideoOrPlyr) {
      return;
    }
  }

  closeDialog();
};
相关推荐
必然秃头3 小时前
前端面试题总结
前端
张雨zy3 小时前
使用nvm管理本地node版本
vue.js·node.js
布列瑟农的星空4 小时前
近一年前端招人面试感悟
前端·面试
mapbar_front4 小时前
从技术到基层管理的跃升
前端·程序员
小码编匠4 小时前
Three.js 遇上 Vue3 开发现代化 3D 可视化编辑系统
vue.js·typescript·three.js
阿豪啊4 小时前
Prisma ORM 入门指南:从零开始的全栈技能学习之旅
javascript·后端·node.js
xuehuayu.cn4 小时前
Chrome 命令行参数生成器
前端·chrome
Eiceblue4 小时前
React 前端实现 Word(Doc/Docx)转 HTML
前端·react.js·word
FogLetter4 小时前
大文件上传?我用分片上传+断点续传彻底解决了!
前端·javascript