element-plus怎么同时实现ElDialog拖动缩放、全屏,ElDialog放大缩小的自定义指令实现 | 青训营笔记

3,377 阅读7分钟

这是我参与「第四届青训营 」笔记创作活动的第1天

前言

在此之前我也搜了一下,大概都是在element-ui实现。而且好像大多数都是使用自定义指令形式实现的。不知道为啥不是封装组件。

不管那么多。既然没有现成的代码可以用,那就自己写一个。

拖拽缩放实现

我们可以先参考一下曾经的弹窗界大佬layer是怎么实现的

layer链接:layer 弹出层组件 - jQuery 弹出层插件 (itze.cn)

image.png

原来是下面有一个看不到的小方块,在小方块身上加上 onmousedown 事件来启动 拖拽缩放事件开始

接下来我们照葫芦画瓢也弄一个就成了

自定义指令钩子:自定义指令 | Vue.js (vuejs.org)

由于vue3支持多根,所以这里指令不能直接加在 <el-dialog> 上面。不然根上的每个元素都会被应用指令导致性能问题,也有可能会导致不符合预期

ElDialog的调用情况

<div v-element-dialog-resize class="el-dialog">
  <el-dialog>
    巴拉巴拉吧一些代码。。。
    <template #footer>
      <el-button class="mr-2.5" @click="dialogIsShowCustomEventTrigger = false">取消</el-button>
      <el-button type="primary" @click="onSubmitCustomEventTrigger">确认</el-button>
    </template>
  </el-dialog>
</div>

自定义指令的实现

src/directive/element-dialog-resize/index.tsx

// 用于在dialog关闭前及时销毁所有监听器
const beforeMountedFun: Array<(...args: any) => any> = []

// 在mounted时刻,dialog可能还处于动画阶段,有可能拿不到某些DOM节点
// 所以需要在mounted和updated钩子上都注册一下
// 并且只能加载一次。所以这里rootEl是用来标记是否已经加载过了
let rootEl: HTMLElement | undefined = undefined
function handle(el: HTMLElement, binding: DirectiveBinding, vnode: any) {
  if (
    !(
      isArray(vnode.children) &&
      (vnode.children[0] as any)?.props?.modelValue &&
      (vnode.children[0] as any)?.component?.subTree?.children[0].el
    )
  )
    return undefined
  nextTick(() => {
    if (rootEl) return undefined
    // 先拿到vnode节点
    const dialogVnode = vnode.children[0].component
    // 然后依次拿到dialog根节点和header body对应的DOM节点,修改一下样式
    rootEl = dialogVnode.subTree.children[0].el as HTMLElement
    const dialogOverlay: HTMLElement = rootEl.querySelector('.el-overlay-dialog')!
    // 隐藏element这个奇怪translate导致拉伸太大而出现滚动条
    dialogOverlay.style.overflow = 'hidden'
    const dialogEl: HTMLElement = rootEl.querySelector('div.el-dialog')!
    // 原本的dialog样式并不支持缩放,会导致dialog大是变大了,但是东西还是在老地方的问题
    // 这里我们可以使用flex让整个dialog自动撑开
    dialogEl.style.display = 'flex'
    dialogEl.style.flexDirection = 'column'
    const dialogBodyEl: HTMLElement = dialogEl.querySelector('.el-dialog__body')!
    dialogBodyEl.style.flexGrow = '1'
    const dialogHeaderEl: HTMLElement = dialogEl.querySelector('header.el-dialog__header')!

    const isEnableResizer = computed(() => true)
    useResizer(dialogEl, isEnableResizer, beforeMountedFun)
  })
}

/**
 * element-plus dialog组件大小缩放功能
 * @param draggable Boolean
 * @example
 *   <div v-element-dialog-resize="{ draggable: true }">
 *     <el-dialog v-model="dialogVisible" title="Tips" width="30%">
 *     </el-dialog>
 *   </div>
 */
export default {
  mounted: handle,
  updated: handle,
  beforeUnmount() {
    for (const fun of beforeMountedFun) {
      if (typeof fun === 'function') {
        fun()
      }
    }
  },
}

我们这里useResizer的目标就是操作DOM,在dialog右下角加上一个透明的小方块resizer,通过onmousedown开启一次缩放

具体的逻辑是:

  1. 找到dialog的DOM对象,插入一个resizer小方块进去
  2. 监听resizer的onmousedown事件触发
  3. 在document中加上onmousemove和onmouseup事件
  4. 在onmousemove事件中计算dialog需要的高度和translate偏移
  5. 监听onmouseup事件,完成缩放操作,并取消onmousemove、onmouseup事件的监听器.

useResizer组合式函数实现

src/directive/element-dialog-resize/use-resizer.ts

// 从element源码里面复制出来的,自动添加单位
// e.g. 11->11px
function addUnit(value?: string | number, defaultUnit = 'px')

// 将translate(11px,22px)解析成{x:11,y:22}
function useParseTranslate(translate: string | undefined) 

export default function useResizer(
  dialogRef: Ref<HTMLElement | undefined> | HTMLElement,
  isEnable: ComputedRef<boolean>
) {
  const dialogEl = unref(dialogRef)!
  dialogEl.style.setProperty('--el-dialog-width', addUnit(dialogEl.getBoundingClientRect().width)!)
  dialogEl.style.setProperty(
    '--el-dialog-height',
    addUnit(dialogEl.getBoundingClientRect().height)!
  )

  // 创建resizer方块
  const resizerEl = document.createElement('div')
  resizerEl.className = 'el-dialog-resizer'
  resizerEl.style.width = '15px'
  resizerEl.style.height = '15px'
  resizerEl.style.zIndex = '3000'
  resizerEl.style.background = 'transparent'
  resizerEl.style.position = 'absolute'
  resizerEl.style.bottom = '0'
  resizerEl.style.right = '0'
  resizerEl.style.cursor = 'se-resize'
  
  // 记录鼠标在resizer上面的偏移
  // 如果不加上这个偏移的话,一旦拖动,那鼠标就会离开resizer,这不是理想的效果
  // 理想的效果应该是鼠标一直悬浮在resizer之上的固定位置
  const mouseOffsetInResizer = { x: 0, y: 0 }
  // 这里每次计算都是使用的 从onmousedown开始时刻 dialog 的大小
  // 而不是MouseEvent事件中给到的鼠标偏移,那个我也试了下,好像不是太准
  let dialogDefaultTranslate: string | undefined = undefined
  let dialogDefaultRect: DOMRect | undefined = undefined
  let resizerDefaultRect: DOMRect | undefined = undefined
  function onMouseMove(ev: MouseEvent) {
    // 节省一下性能,每一帧计算一次
    requestAnimationFrame(() => {
      // 这里其实很简单 假设是向右下角拖拽 使其放大
      // 那么开始前的width和目标width的差值 计算方法就是
      // deltaWidth = 鼠标x坐标 - dialog右侧坐标 + 鼠标x与resizer的差值
      // 我们可以知道,鼠标一定是在resizer里面的
      // 所以 鼠标x与resizer的差值 = resizer的右侧坐标 - 鼠标x坐标
      const deltaWidth = ev.clientX - dialogDefaultRect!.right + mouseOffsetInResizer.x
      const deltaHeight = ev.clientY - dialogDefaultRect!.bottom + mouseOffsetInResizer.y
      const { x: translateX, y: translateY } = useParseTranslate(dialogDefaultTranslate)

      const newWidth = `${dialogDefaultRect!.width + deltaWidth}px`
      const newHeight = `${dialogDefaultRect!.height + deltaHeight}px`
      dialogEl.style.setProperty('--el-dialog-width', newWidth)
      dialogEl.style.setProperty('--el-dialog-height', newHeight)
      dialogEl.style.width = newWidth
      dialogEl.style.height = newHeight
      
      // 这里要要注意,我们宽度拉大之后,dialog是从中间放大的。
      // 所以要把dialog整体向右边在平移 deltaWidth / 2 的距离
      dialogEl.style.transform = `translate(${translateX + deltaWidth / 2}px,${translateY}px)`
    })
  }

  // resize事件开始
  const onMouseDown = (ev: MouseEvent) => {
    document.body.style.userSelect = 'none'
    document.body.style.cursor = 'se-resize'
    
    // 记录初始坐标
    dialogDefaultRect = dialogEl.getBoundingClientRect()
    resizerDefaultRect = resizerEl.getBoundingClientRect()
    // 鼠标x与resizer的差值 = resizer的右侧坐标 - 鼠标x坐标
    mouseOffsetInResizer.x = resizerDefaultRect.right - ev.clientX
    mouseOffsetInResizer.y = resizerDefaultRect.bottom - ev.clientY
    dialogDefaultTranslate = dialogEl.style.transform === '' ? undefined : dialogEl.style.transform
    
    // resize事件结束
    function onMouseUp() {
      requestAnimationFrame(() => {
        // 清空所有初始参数,准备下一次resize
        dialogDefaultRect = undefined
        resizerDefaultRect = undefined
        dialogDefaultTranslate = undefined
        document.body.style.userSelect = ''
        document.body.style.cursor = ''
      })
      // 记得移除无用的监听器
      document.removeEventListener('mousemove', onMouseMove)
      document.removeEventListener('mouseup', onMouseUp)
    }
    
    // resize事件中
    document.addEventListener('mousemove', onMouseMove)
    
    // resize事件结束监听器
    document.addEventListener('mouseup', onMouseUp)
  }

  // 开启resize功能
  const onResizer = () => {
    if (resizerEl && dialogEl) {
      resizerEl.addEventListener('mousedown', onMouseDown)
      dialogEl.appendChild(resizerEl)
    }
  }
}

至此拖拽缩放的效果基本上就出来了。

不过由于element那个奇怪的translate,拖拽缩放之后,如果想要再进行平动的话,就会产生一个抖动的效果

20220817125823.gif

解决平动抖动问题

在看了dialog实现之后,可以知道这个bug出现的原因在于我们改变了translate,但是dialog他并不知道,所以照成他计算上下左右边距出错

image.png

那么解决问题的点也在这里,只要让他每次平动都重新获取最新的translate就行了

这里我们复制一份他的packages/hooks/use-draggable/index.ts进行一些微调即可

useDraggable实现

src/directive/element-dialog-resize/use-draggable.ts

const useDraggable = (
  targetRef: Ref<HTMLElement | undefined> | HTMLElement,
  dragRef: Ref<HTMLElement | undefined> | HTMLElement,
  draggable: ComputedRef<boolean>,
  beforeMountedFun: Array<(...args: any) => any>
) => {
  const targetRef_ = unref(targetRef)!
  const dragRef_ = unref(dragRef)!

  const onMousedown = (e: MouseEvent) => {
    // 只有这里需要修改,其他地方保持不动就好了
    // 每次都重新获取一下transform的值
    const { x: offsetX, y: offsetY } = useParseTranslate(targetRef_!.style.transform)
    ...
}
export default useDraggable

至此拖拽缩放就完成了

切换全屏实现

现在还有个需求,要求dialog在右上角的关闭icon左边加一个全屏icon按钮,能够随时切换全屏与窗口

image.png

默认的dialog是提供了fullscreen的props,但是没有在右上角提供插槽

所以就没办法通过slot在右上角X的位置加一个全屏的图标了

这里我的实现思路是

  1. 操作DOM把dialog原本的图标给替换掉
  2. 拿到dialog的vnode
  3. 给fullscreenIcon和closeIcon绑定click事件
  4. 调用vnode切换dialog的fullscreen属性

useFullscreen实现

src/directive/element-dialog-resize/use-fullscreen.tsx

export default function useFullscreen(dialogEl: HTMLElement, dialogVnode: any) {
  let allowDraggable = false
  function onToggleFullScreen() {
    if (dialogEl.className.includes('is-draggable')) allowDraggable = true
    dialogVnode.props.fullscreen = !dialogVnode.props.fullscreen
    if (!dialogVnode.props.fullscreen && allowDraggable) {
      // 从全屏变回窗口,要加上 is-draggable
      // 如果少了这里的话,全屏然后再切换回来会导致header栏丢失cursor样式
      nextTick(() => {
        dialogEl.className = `${dialogEl.className} is-draggable`
      })
    }
  }
  function onCloseDialog() {
    dialogVnode.props.modelValue = false
  }

  const dialogHeaderEl: HTMLElement = dialogEl.querySelector('header.el-dialog__header')!
  
  //找到dialog右上角原本的关闭按钮,给他删了
  const dialogHeaderButtonEl: HTMLElement | null =
    dialogHeaderEl.querySelector('.el-dialog__headerbtn')
  if (dialogHeaderButtonEl) {
    dialogHeaderEl.removeChild(dialogHeaderButtonEl)
  }
  
  // 加上我们自己的两个按钮
  const customDialogHeaderButtonEl = document.createElement('div')
  customDialogHeaderButtonEl.className = 'el-dialog__headerbtn'
  customDialogHeaderButtonEl.style.display = 'flex'
  customDialogHeaderButtonEl.style.alignItems = 'center'
  customDialogHeaderButtonEl.style.width = 'auto'
  customDialogHeaderButtonEl.style.marginRight = '20px'

  // 全屏按钮
  const iconFullscreenContainer = document.createElement('div')
  const iconFullscreenVnode = <IconFullScreen onClick={onToggleFullScreen} />
  render(iconFullscreenVnode, iconFullscreenContainer)
  customDialogHeaderButtonEl.appendChild(iconFullscreenContainer)

  // 关闭按钮
  const iconCloseContainer = document.createElement('div')
  iconCloseContainer.style.marginLeft = '20px'
  const iconCloseVnode = <IconClose onClick={onCloseDialog} />
  render(iconCloseVnode, iconCloseContainer)
  customDialogHeaderButtonEl.appendChild(iconCloseContainer)

  dialogHeaderEl.appendChild(customDialogHeaderButtonEl)

  // 加入覆盖样式
  // 由于放大缩小那边直接把--el-dialog-width写成了内联样式。
  // 所以这里只能用!important去覆盖上面的,不然这里无法实现全屏
  const style = document.createElement('style')
  style.type = 'text/css'
  style.innerHTML =
    '.el-dialog.is-fullscreen {--el-dialog-width: 100%!important;width:100%!important;height:100%!important;}'
  document.querySelector('head')!.appendChild(style)
}

至此拖拽缩放、全屏功能就全部实现完成了。

不足的地方在于使用这个指令的之后需要在 <el-dialog> 标签外面再包一层标签才行

完整源码

完整的代码可以参考一下这里的

cow-Low-code/index.ts at main · Cow-Coder/cow-Low-code (github.com)