这是我参与「第四届青训营 」笔记创作活动的第1天
前言
在此之前我也搜了一下,大概都是在element-ui实现。而且好像大多数都是使用自定义指令形式实现的。不知道为啥不是封装组件。
不管那么多。既然没有现成的代码可以用,那就自己写一个。
拖拽缩放实现
我们可以先参考一下曾经的弹窗界大佬layer是怎么实现的
原来是下面有一个看不到的小方块,在小方块身上加上 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开启一次缩放
具体的逻辑是:
- 找到dialog的DOM对象,插入一个resizer小方块进去
- 监听resizer的onmousedown事件触发
- 在document中加上onmousemove和onmouseup事件
- 在onmousemove事件中计算dialog需要的高度和translate偏移
- 监听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,拖拽缩放之后,如果想要再进行平动的话,就会产生一个抖动的效果
解决平动抖动问题
在看了dialog实现之后,可以知道这个bug出现的原因在于我们改变了translate,但是dialog他并不知道,所以照成他计算上下左右边距出错
那么解决问题的点也在这里,只要让他每次平动都重新获取最新的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按钮,能够随时切换全屏与窗口
默认的dialog是提供了fullscreen的props,但是没有在右上角提供插槽
所以就没办法通过slot在右上角X的位置加一个全屏的图标了
这里我的实现思路是
- 操作DOM把dialog原本的图标给替换掉
- 拿到dialog的vnode
- 给fullscreenIcon和closeIcon绑定click事件
- 调用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)