vue3实现可以拖动的弹窗组件, 做这个组件可以学习到什么?

vue3实现一个可以拖动的弹窗组件, 实现这个组件可以学习到什么?

背景是我们有一个老的项目jsp,他里面有一套样式规范,之前好像是使用的Bootstrap UI

本次开发部分模块以iframe 的方式嵌套进去,为了更好的维护以及迭代,我们本次技术栈使用vue3

为了满足UI统一 需要定制一个弹窗组件,这个弹窗组件需要支持拖动,这个拖动还有点不太一样,是拖动了一个跟弹窗同等大小黑色的框 移动,等松开鼠标的时候再移动原弹窗的位置。具体样式如下:

以及这个项目的二次确认弹窗 ,以及成功的提示也是长这个样子支持拖动,如下就是多个图标:

组件的设计 pro-modal

先说下<pro-modal />组件需要支持到什么以及需要提供给用户什么样的API,我觉得一个弹窗组件咱们直接去参考element-ui或者antd ui就行了。具体如下:

API:

  • title 弹窗标题
  • visible 控制显示隐藏
  • width 设置宽度支持数字、像素、百分比等
  • isFullModal 是否全屏
  • isWindowOnly 是否限制在屏幕适口内拖动
  • closable 是否显示右上角关闭按钮

Slot:

  • default body
  • header 定制头部左上角
  • suffix 定制头部右上角
  • footer 定制底部

用法:

vue 复制代码
<template>
  <ProModal v-model:visible="visible" title="AAAAA">
    1111
  </ProModal>
</template>

<script setup>
import { ref } from 'vue'
import ProModal from '@/components/pro-modal/index.vue'
const visible = ref(false)
</script>

以上就是本次<pro-modal /> 组件的设计以及使用方法

基于 pro-modal 组件开发一个二次确认提示框 pro-confirm

拖拽等功能已经是pro-modal的基础能力,pro-confirm需要支持到以下用法:

用法一:

vue 复制代码
<template>
 <ProConfirm content="确定删除吗?" :type="CONFIRM_MAP.SUCCESS" :confirm="onConfirm" :cancel="onCancel">
    <a-button type="primary">
      删除
    </a-button>
</ProConfirm>
</template>

<script setup>
import { CONFIRM_MAP, ProConfirm } from '@/components/pro-confirm/index.js'


const onConfirm = () => {}

const onCancel = () => {}
</script>

用法二:

vue 复制代码
<template>
 <a-button type="primary" @click="onClick">
    保存
 </a-button>
  <a-button type="primary" @click="onDeleteClick">
    删除
 </a-button>
</template>


<script setup>
import { Confirm } from '@/components/pro-confirm/index.js'


const onClick = () => {
  Confirm.success('新增成功')
  /**
   * 
   * Confirm.success({
   *  content: '新增成功'
   * })
   */
}


const onDeleteClick = () => {
    Confirm.warning({
      content: '确认删除吗?',
      confirm() {

      },
      cancel() {

      }
    })
}
</script>

用法其实就是参考二次确认弹窗以及信息提示弹窗组件的实现,行那么接下来咱们来实现下相关具体功能。

pro-modal 实现的具体细节

处理width字段 设置宽度支持数字、像素、百分比等,匹配到数值就增加个px。如下:

js 复制代码
const widthStyle = computed(() => {
  if (/^\d*$/.test(props.width)) {
    return `width: ${props.width}px`
  }

  return `width: ${props.width}`
})

由于咱们的弹窗是支持拖动的,那么为了让弹窗内容居中 ,就不能使用margin: 0 auto;,有或者tarnsform: translateX(-50%)手段了,必须要使用定位的left才好去做拖动 逻辑。

其实就是把屏幕的宽度比做一个大矩形 ,弹窗中的内容比做小矩形 ,现在需要把小矩形居中就使用 (大矩形宽度 - 小矩形宽度)/ 2 就得到**居中*8的left值了。代码如下:

js 复制代码
watch(
  () => props.visible,
  value => {
    if (value) {
      nextTick(() => {
        // getBoundingClientRect 获取到内容的宽度
        const { width: conatinerWidth } = containerRef.value?.getBoundingClientRect() || {
          width: 0,
          height: 0
        }
        const maxWidth = window.innerWidth // 获取到屏幕的宽度
        currentLeft.value = (maxWidth - conatinerWidth) / 2 // 得到left 值 (这里是有计算属性的,赋值后就会使用left),这里说明下我的top值初始化是100px,我看好多弹窗组件都是默认100px

        onBindEvent()
      })
    }
  },
  {
    immediate: true
  }
)

拖动黑色边框的样式也是需要计算的,它的宽度、高度、left、Top值初始化都是以弹窗内容基础去做的,然后在根据拖动的距离动态更新它的left、top值。具体如下:

js 复制代码
const borderStyle = computed(() => {
  const { width: conatinerWidth, height: containerHeight } = containerRef.value?.getBoundingClientRect() || {
    width: 0,
    height: 0
  }
  // diff 其实就是拖动的距离 动态加减即可实现拖动效果

  return `
      width: ${conatinerWidth}px;
      height: ${containerHeight}px; 
      left: ${currentLeft.value + diffX.value}px;  
      top: ${currentTop.value + diffY.value}px; 
    `
})

会有场景需要弹窗嵌套弹窗 的场景,那么就需要根据加载的顺序,动态生成权重z-index: 666;,在有弹窗存在就权重1。具体如下:

js 复制代码
const DEFAULT_INDEX = 698

const indexList = []

// 增加
export const incrementalZIndex = () => {
  const zIndex = Math.max(...indexList, DEFAULT_INDEX) + 1
  indexList.push(zIndex)
  return zIndex
}

// 在取消弹窗调用删除
export const decreaseZIndex = zIndex => {
  const i = indexList.findIndex(item => item === zIndex)

  if (i !== -1) {
    indexList.splice(i, 1)
  }
}

需要计算只能在当前屏幕内拖动,其实范围就是限制下定位的Left、Top , 最小值肯定是left: 0; top: 0;, 最大值一定是left: 屏幕的宽 - 弹窗内容的宽; top: 屏幕的高 - 弹窗内容的高。 如果有滚动条 ,需要把滚动条的宽度 给计算进去。都在useDrag函数里面统一放下面了

js 复制代码
import { onUnmounted, ref } from 'vue'
import { getScrollBarWidth } from './scroll-bar'

export const useDrag = (containerRef, draggableRef, options = {}) => {
  const { isWindowOnly = true, margin = 0, onEndCallback = () => {} } = options || {}

  const scopeValue = ref({})
  const isDragging = ref(false)
  const startX = ref(0)
  const startY = ref(0)
  const diffX = ref(0)
  const diffY = ref(0)

  const startDrag = ({ clientX: x1, clientY: y1 }) => {
    scopeValue.value = getScopeValue() // 检测范围
    document.onselectstart = () => false
    startX.value = x1
    startY.value = y1
    isDragging.value = true
  }

  const moveDrag = ({ clientX: x2, clientY: y2 }) => {
    if (isDragging.value) {
      if (isWindowOnly) {
        // 判断检测要在范围内
        const { maxX, maxY, x, y } = scopeValue.value

        const vx = x2 - startX.value + x
        const vy = y2 - startY.value + y

        let dx = x2 - startX.value
        let dy = y2 - startY.value

        if (vx < 0) {
          dx = -x
        }
        if (vy < 0) {
          dy = -y
        }

        if (vx > maxX) {
          dx = maxX - x
        }

        if (vy > maxY) {
          dy = maxY - y
        }

        diffX.value = dx
        diffY.value = dy
      } else {
        diffX.value = x2 - startX.value
        diffY.value = y2 - startY.value
      }
    }
  }

  const endDrag = () => {
    document.onselectstart = () => true
    onEndCallback(diffX.value, diffY.value)
    diffX.value = 0
    diffY.value = 0
    isDragging.value = false
  }

  const getScopeValue = () => {
    const {
      width: conatinerWidth,
      height: containerHeight,
      x,
      y
    } = containerRef.value?.getBoundingClientRect() || {
      width: 0,
      height: 0
    }
    const maxWidth = window.innerWidth
    const maxHeight = window.innerHeight
    const isScroll = document.body.scrollHeight > window.innerHeight

    return {
      minX: 0,
      minY: 0,
      maxX: maxWidth - conatinerWidth - margin - (isScroll ? getScrollBarWidth() : 0),
      maxY: maxHeight - containerHeight - margin,
      x,
      y
    }
  }

  const onBindEvent = () => {
    if (draggableRef.value) {
      draggableRef.value?.removeEventListener('mousedown', startDrag)
      document?.removeEventListener('mousemove', moveDrag)
      document?.removeEventListener('mouseup', endDrag)
      draggableRef.value.addEventListener('mousedown', startDrag, { capture: true })
      document.addEventListener('mousemove', moveDrag)
      document.addEventListener('mouseup', endDrag)
    }
  }

  onUnmounted(() => {
    draggableRef.value?.removeEventListener('mousedown', startDrag)
    document?.removeEventListener('mousemove', moveDrag)
    document?.removeEventListener('mouseup', endDrag)
  })

  return {
    onBindEvent,
    isDragging,
    diffX,
    diffY
  }
}

pro-confirm 实现的一些细节

支持嵌套点击出现确认框实现如下:

vue 复制代码
<template>
  <div @click="onClick" class="pro-confirm">
    <slot></slot>
    <ProModal
      wrapClassName="pro-confirm__warp31415925"
      :title="title"
      :icon="icon"
      :width="260"
      :closable="false"
      :visible="confirmVisible"
    >
      <div class="pro-confirm__content">
        <span class="pro-confirm__icon" :style="typeComputed"></span>
        <span> {{ content }}</span>
      </div>

      <template #footer>
        <a-button size="small" @click="onConfirm">{{ okText }}</a-button>
        <a-button size="small" @click="onCancel" v-if="showCancel">{{ cancelText }}</a-button>
      </template>
    </ProModal>
  </div>
</template>

<script>
const onClick = () => {
  if (props.disabled) return
  confirmVisible.value = true // 更改状态 关闭
}
</script>

支持函数调用的方式使用组件(核心):

js 复制代码
import { createApp } from 'vue'
import ProConfirm from './index.vue'

const mountNode = document.createElement('div')
  document.body.appendChild(mountNode)
  const modalInstance = createApp(ProConfirm, {
    visible: true,
    ...currentOptions,
    confirm() {
      currentOptions.confirm()
      mountNode.remove()
    },
    cancel() {
      currentOptions.cancel()
      mountNode.remove()
    }
  })
  modalInstance.mount(mountNode) // 挂载

完整的方法支持success、error、warning:

js 复制代码
import { createApp } from 'vue'

import ProConfirm from './index.vue'
import { CONFIRM_MAP } from './options'

const DEFAULT_OPTIONS = {
  title: '提示信息',
  type: CONFIRM_MAP.SUCCESS,
  content: '',
  cancelText: '取消',
  okText: '确定',
  showCancel: true,
  confirm: () => {},
  cancel: () => {}
}

const Confirm = options => {
  const currentOptions = {
    ...DEFAULT_OPTIONS,
    ...options
  }
  if (currentOptions.type === CONFIRM_MAP.SUCCESS) {
    Confirm.success()
  }
  if (currentOptions.type === CONFIRM_MAP.ERROR) {
    Confirm.error()
  }
  if (currentOptions.type === CONFIRM_MAP.WARNING) {
    Confirm.warning()
  }
}

const confirmInstance = options => {
  const currentOptions = {
    ...DEFAULT_OPTIONS,
    ...options
  }

  const mountNode = document.createElement('div')
  document.body.appendChild(mountNode)
  const modalInstance = createApp(ProConfirm, {
    visible: true,
    ...currentOptions,
    confirm() {
      currentOptions.confirm()
      mountNode.remove()
    },
    cancel() {
      currentOptions.cancel()
      mountNode.remove()
    }
  })
  modalInstance.mount(mountNode)
}

const paramsParse = (options, type) => {
  let currentOptions = {
    type
  }

  if (typeof options === 'string') {
    currentOptions.content = options
    currentOptions.showCancel = false
  } else {
    currentOptions = {
      ...options,
      type
    }
  }

  return currentOptions
}

Confirm.success = (options = DEFAULT_OPTIONS) => {
  confirmInstance(paramsParse(options, CONFIRM_MAP.SUCCESS))
}

Confirm.error = (options = DEFAULT_OPTIONS) => {
  confirmInstance(paramsParse(options, CONFIRM_MAP.ERROR))
}

Confirm.warning = (options = DEFAULT_OPTIONS) => {
  confirmInstance(paramsParse(options, CONFIRM_MAP.WARNING))
}

// Confirm.success({
//   content: '新增成功'
// })

// Confirm.success('新增成功')
export { ProConfirm, CONFIRM_MAP, Confirm }

总结

实现一个可拖拽的弹窗组件以及二次确认弹窗,还是有好多细节可学习的:

  • width Api兼容
  • 居中算法
  • 权重z-index: 666;递增
  • 屏幕内拖动范围限制
  • 实现二次确认弹窗click利用事件冒泡
  • 使用createApp实现函数式APIsuccess、error、warning、Confirm

源码地址链接

相关推荐
正宗咸豆花1 小时前
【PromptCoder + Bolt.new】Cascade模式自动生成页面和对应的路由
前端·人工智能·ai·prompt·提示词
roamingcode2 小时前
前端组件标准化专家Prompt指令的最佳实践
前端·prompt
LXY202305043 小时前
css三角图标
前端·javascript·css
spring_007_9993 小时前
在uniapp中修改打包路径
前端·uni-app
cheese-liang4 小时前
Excel中Address函数的用法
前端·excel
prince_zxill4 小时前
JavaScript 中的 CSS 与页面响应式设计
前端·javascript·css·前端框架·html
苹果醋34 小时前
Kubeflow——K8S的机器学习利器
运维·vue.js·spring boot·nginx·课程设计
林涧泣4 小时前
【Uniapp-Vue3】获取用户状态栏高度和胶囊按钮高度
前端·vue.js·uni-app
领秀58585 小时前
我问了DeepSeek和ChatGPT关于vue中包含几种watch的问题,它们是这么回答的……
前端·javascript·vue.js
prince_zxill5 小时前
使用 Postman 进行 API 测试:从入门到精通
javascript·网络·websocket·测试工具·postman