前言
之前我们用 插件化的方式开发了拖拽通用库,并且基于这个库实现了 拖拽排序,换个思路轻松搞定 的效果和 穿梭的拖拽排序,轻松拿捏 的效果,本文将基于这个库在实现一个目前低代码项目中广泛使用的跨 Iframe 拖拽辅助线插件
,效果图先呈上
正篇开始前的一些问题解答
如何跨 Iframe?
一般在低代码项目中,为了布局、逻辑不污染全局,往往需要用到 iframe 元素,至于怎么用 iframe 元素作为低代码画布区域,无非就2种方式,这里我以Vue3 框架
为例说明
-
使用
路由(vue-router)
+iframe 的 src 属性
结合优点:没有样式丢失的问题
缺点:重载加载了一次应用,渲染速度可能会变慢
-
使用
render 函数
将画布内容挂载到iframe
元素中优点:渲染速度跟正常组件一样,数据状态依旧保持
缺点:一些
link
的样式以及动态新增的样式会丢失,需要初始化拷贝一次
+监听动态新增的样式再次拷贝
,监听可以使用MutationObserve
搞定,此方法在搭配组件库的一些弹出类组件,如(dialog、modal)时会有一些奇怪的问题,具体原因没有深入排查,反正我使用的组件库没问题(😁)
回到正题,我们使用了 iframe 后拖拽事件就获取不到 iframe
内的元素了,也就是说 event.target
获取到的是 iframe 元素
,而不是内部的元素,这个我们其实可以给 iframe
元素绑定事件,比如我们拿到 iframe
中的 document
,然后给 document
绑定事件即可,类似如下代码
useEventListener(iframeGetter, 'load', () => {
useEventListener(iframeDocumentGetter, 'mousedown', event => handleMouseDown(event, unref(iframeGetter)!))
useEventListener(iframeDocumentGetter, 'mousemove', event => handleMouseMove(event, unref(iframeGetter)!))
useEventListener(iframeDocumentGetter, 'mouseup', event => handleMouseUp(event, unref(iframeGetter)!))
})
iframeGetter
:iframe
元素iframeDocumentGetter
:iframe
中的document
可以看到,我们是通过监听 iframe 的 load
事件,等到加载完成后再执行拖拽事件的绑定,但是有一些情况是不会触发 load
事件的,比如我们没有指定 src
属性,而是利用 render
函数直接挂载到 iframe
元素中,这种情况我们在开发时也需要特殊处理一下
其实以上针对iframe
的处理我是在插件的上下文中统一处理了,下文我们实现插件时不需要去关注这些逻辑,插件只需要监听拖拽过程中的钩子即可
实现辅助线效果需要输出哪些参数
其实看效果我们可以看到,就是一条线,只不过这条线的宽度
、高度
、位置
在变化而已,宽度
、高度
是根据参考元素
得出,位置
相对比较复杂一点,需要考虑的一些情况如下
- 鼠标位置
- 元素布局
针对鼠标位置
这个情况,我们可以分为5
种方向,分别是上下左右
和在内部
,我们定义一个枚举示例一下
enum DirectionEnum {
LEFT = 'LEFT',
RIGHT = 'RIGHT',
TOP = 'TOP',
BOTTOM = 'BOTTOM',
IN = 'IN',
}
而如何计算方向还要根据元素布局
情况而定,比如grid
布局,我们设定属性gridTemplateColumns
只有1列
,如下图所示
比如我们此时鼠标在grid-item--2
边缘,我们的辅助线应该出现在上或者下
,而不是左或者右
,还有其他一些布局、宽度的因素,我们都会在实现插件的过程中考虑进去,当然针对布局我们可以按大类分成2
种情况,分别是水平
和垂直
,我们定义一个枚举示例一下
enum LayoutEnum {
VERTICAL = 'VERTICAL',
HORIZONTAL = 'HORIZONTAL',
}
好了,说完上面的情况,我们可以得知辅助线要被绘制出来需要以下的参数
- 参考元素
- 方向
- event
我们定义一个枚举示例一下
interface AuxiliaryLineLocation {
element: HTMLElement
event: EnhancedMouseEvent
direction: `${DirectionEnum}`
}
绘制布局 + 引入鼠标跟随插件
这部分代码没有太多营养价值,我给贴上代码后稍微解释一下即可
<script setup lang="tsx">
import { useDragDrop } from '@drag-drop/core'
import { mouseFollowPlugin } from '@drag-drop/plugin-mouse-follow'
import { computed, ref } from 'vue'
import { IframeContainer } from '../../IframeContainer'
const iframeInstRef = ref()
const context = useDragDrop({
canDraggable: event => !!event.target?.classList.contains('materiel-item'),
canDropable: event => !!event.target?.classList.contains('node'),
frames: [
computed(() => iframeInstRef.value?.$el),
],
})
context.use(mouseFollowPlugin({
text: event => event?.target?.textContent ?? '',
}))
</script>
<template>
<div class="container">
<div class="left-panel">
<div class="materiel-item">
物料1
</div>
<div class="materiel-item">
物料2
</div>
<div class="materiel-item">
物料3
</div>
<div class="materiel-item">
物料4
</div>
<div class="materiel-item">
物料5
</div>
</div>
<div class="canvas-panel">
<IframeContainer ref="iframeInstRef" :show-title="false">
<div :style="{ display: 'flex', gap: '10px', padding: '5px', border: '1px solid #ccc', userSelect: 'none' }" class="node">
<span :style="{ color: 'red', border: '1px solid #ccc' }" class="node">节点1</span>
<span :style="{ color: 'pink', border: '1px solid #ccc' }" class="node">节点2</span>
<span :style="{ color: 'skyblue', border: '1px solid #ccc' }" class="node">节点3</span>
</div>
</IframeContainer>
</div>
</div>
</template>
<style scoped>
.container{
display: flex;
margin: 100px 0 0 0;
}
.left-panel{
display: flex;
flex-wrap: wrap;
width: 300px;
gap: 10px;
user-select: none;
}
.materiel-item{
display: flex;
align-items: center;
justify-content: center;
width: 70px;
height: 70px;
border: 1px solid #ccc;
}
</style>
解释一下一些内容
useDragDrop
:之前用 插件化的方式开发了拖拽通用库,里面封装了拖拽的一些事件,处理了iframe
的情况,并向外暴露了一些钩子canDraggable
参数:指定哪些元素可以被拖拽canDropable
参数:指定哪些元素可以被放置,比如我们规定了只有类型为 node
的元素才能显示辅助线
frames
参数:需要跨哪些iframe
元素,我们这里只有一个iframe
mouseFollowPlugin
:根据鼠标坐标对可以被拖拽的元素显示一些内容IframeContainer
组件:内部将默认插槽的内容通过render
函数渲染到一个iframe
元素中
好了,上面代码实现后页面效果如下
插件实现
我们将这个插件命名为auxiliaryLinePlugin
,可以被如下方式调用
const context = useDragDrop(...)
context.use(auxiliaryLinePlugin())
接下来我们先将这个插件的外壳搞定,代码如下
interface AuxiliaryLineLocation {
element: HTMLElement
event: EnhancedMouseEvent
direction: `${DirectionEnum}`
}
interface AuxiliaryLinePluginOptions extends Partial<Omit<DrapDropEventsCallback, 'onDragging'>> {
thresholdSize?: number
auxiliaryLineSize?: number
onRender?: (location: AuxiliaryLineLocation | undefined) => VNodeChild
}
export function auxiliaryLinePlugin(options: AuxiliaryLinePluginOptions = {}) {
return function ({ context }: DragDropPluginCtx) {
const {
onEnd,
onMove,
onStart,
onRender,
thresholdSize = 8,
auxiliaryLineSize = 2,
} = options
const dropableRef = context.useCanDropable()
const auxiliaryLineLocationRef = ref<AuxiliaryLineLocation>()
context.onStart((event) => {
onStart?.(event)
})
context.onMove((event) => {
if (!unref(dropableRef)) return
// 我们等下要在这里实现
onMove?.(event)
})
context.onEnd((event) => {
onEnd?.(event)
auxiliaryLineLocationRef.value = undefined
})
return () => {
return <div>12312312</div>
}
}
}
解释一下
- 插件必须是一个函数,在内部会将这个函数包装成
vue 的 setup
函数,所以可以使用响应式Api
及渲染函数
auxiliaryLineSize
参数:辅助线的大小,默认为2
onRender
参数:用户可以自定义渲染内容,不一定非要是一根线onStart
参数:拖拽开始触发的钩子onMove
参数:拖拽移动时触发的钩子onEnd
参数:拖拽结束后触发的钩子useCanDropable
:辅助线是否应该出现,我们之前规定了这个参数必须在类名为 node
的元素上才能出现thresholdSize
参数:阈值大小范围,鼠标移入到一定的范围后取的兄弟元素不一样,举个列子,看下图
如果鼠标点在某一个元素的阈值大小范围外
,那么我们应该去找iframe
中的直接子节点,也就是父元素1
、父元素2
离鼠标最近的一个元素,然后辅助线应该出现在父元素1
或者父元素2
的上下方向
,那如果在阈值大小范围内
,如下图
我们应该计算父元素1
中的直接子节点,也就是元素1
、元素2
、元素3
离鼠标最近的一个元素,然后辅助线应该出现在这3个元素中的某一个方向上
计算方向和参考元素前的准备工作
在计算方向和参考元素前,我们还需要得知以下一些内容
- 需要得到是不是在
阈值大小范围内
- 根据
是否在阈值大小范围内
得到兄弟元素
和父元素
- 根据
兄弟元素
和父元素
判断布局
我们如果知道了布局
和兄弟元素
,我们就可以算出兄弟元素
中哪2
个元素离鼠标最近,为什么这里是2
个元素呢,那是因为鼠标点可能是下面这种情况
像上图,鼠标可能在2
个元素中间位置,我们要算出这2
个元素哪个离鼠标更近一点,从而得到参考元素和方向
好了,咱们根据列举的3条内容一个个单独实现
内容1(需要得到是不是在阈值大小范围内
):我们可以很简单的实现,代码如下
export function isInThresholdRange(event: EnhancedMouseEvent, thresholdSize: number) {
const { x, y, target } = event.originEvent // 获取鼠标 x,y 坐标
const { left, right, top, bottom } = getBoundingClientRect(target) // 获取元素的一些坐标值
// ---start
// start 到 end 是 根据阈值范围大小 缩小了元素
const thresholdTop = top + thresholdSize
const thresholdLeft = left + thresholdSize
const thresholdRight = right - thresholdSize
const thresholdBottom = bottom - thresholdSize
// --end
const xIsInThresholdRange = x >= thresholdLeft && x <= thresholdRight
const yIsInThresholdRange = y >= thresholdTop && y <= thresholdBottom
// 判断鼠标是否在缩小的元素范围内
return xIsInThresholdRange && yIsInThresholdRange
}
内容2(根据是否在阈值大小范围内
得到兄弟元素
和父元素
):我们也可以很简单的实现,代码如下
function getElementChildrenAndContainer(event:EnhancedMouseEvent, inThresholdRange:boolean) {
const element = event.originEvent.target as HTMLElement;
if (inThresholdRange) {
// 如果在阈值范围内,直接获取当前元素和当前元素下的子节点,当然这里我们过滤掉了文本节点
return {
container: element,
children: Array.from(element.childNodes).filter(isElementNode) as HTMLElement[],
}
}
// 如果不在阈值范围内,获取当前元素的父元素及父元素下的子节点,当然这里我们过滤掉了文本节点
return {
container: element.parentElement!,
children: Array.from(element.parentElement!.childNodes).filter(isElementNode) as HTMLElement[],
}
},
如果有同学对是否在阈值范围内获取的父元素和兄弟元素不太明白的话
,建议看下我上面在插件实现
标题下对thresholdSize
参数画的图
内容3(根据兄弟元素
和父元素
判断布局)
const inThresholdRange = isInThresholdRange(event,thresholdSize)
const { container, children } = getElementChildrenAndContainer(event,inThresholdRange)
if (children.length <= 0) {
// 如果没有孩子,说明方向是 IN
return {
event,
element,
direction: DirectionEnum.IN,
}
}
// 取出第一个元素,看该元素在父元素中的布局
const firstElement = children[0]
const layout = calculateLayout(firstElement, container)
function calculateLayout(element: HTMLElement, container: HTMLElement) {
// isFlexLayout方法:判断是否为 flex 布局
if (isFlexLayout(container)) {
// isFlexRowDirectionLayout方法:判断 flex-direction === 'row' || flex-direction === 'row-reverse'
return isFlexRowDirectionLayout(container) ? LayoutEnum.HORIZONTAL : LayoutEnum.VERTICAL
}
// isGridLayout方法:判断是否为 grid 布局
if (isGridLayout(container)) {
// getGridTemplateColumnsLen方法:看 grid 布局一行有多少列
const len = getGridTemplateColumnsLen(container)
return len <= 1 ? LayoutEnum.VERTICAL : LayoutEnum.HORIZONTAL
}
// isBlockLayout方法:是否为块级布局,判断 element.display === 'block'
// isFlexLayout方法:是否为flex布局,判断 element.display === 'flex'
// isGridLayout方法:是否为grid布局,判断 element.display === 'grid'
// isTableLayout方法:是否为表格布局,判断 element.display === 'table'
// widthIsFullContainer方法:子元素宽度是否撑满父元素,下面我会单独写出该方法
if (
isBlockLayout(element)
|| isFlexLayout(element)
|| isGridLayout(element)
|| isTableLayout(element)
|| widthIsFullContainer(element, container)
) {
return LayoutEnum.VERTICAL
}
return LayoutEnum.HORIZONTAL
}
export function widthIsFullContainer(element: HTMLElement, container: HTMLElement) {
const elementStyle = window.getComputedStyle(element)
const containerStyle = window.getComputedStyle(container)
// pxToNumber方法:将px单位忽略,并将值转换成number类型
const elementFullWidth = pxToNumber(elementStyle.width)
+ pxToNumber(elementStyle.paddingLeft)
+ pxToNumber(elementStyle.paddingRight)
+ pxToNumber(elementStyle.marginLeft)
+ pxToNumber(elementStyle.marginRight)
+ pxToNumber(elementStyle.borderLeft)
+ pxToNumber(elementStyle.borderRight)
const containerInnerWidth = pxToNumber(containerStyle.width)
- pxToNumber(containerStyle.paddingLeft)
- pxToNumber(containerStyle.paddingRight)
- pxToNumber(containerStyle.marginLeft)
- pxToNumber(containerStyle.marginRight)
- pxToNumber(containerStyle.borderLeft)
- pxToNumber(containerStyle.borderRight)
return elementFullWidth >= containerInnerWidth
}
计算方向和参考元素
我们在计算方向和参考元素的准备工作中,知道了一些重要信息,这里我列举一下
- 知道了
兄弟元素
- 知道了
布局模式
接下来咱们要做的事情我说明一下
- 根据
兄弟元素
和布局模式
获取到鼠标的前后2
个元素 - 根据
鼠标的前后2个元素
得到最近的参考元素
和方向
还是一个个实现
内容1(根据兄弟元素
和布局模式
获取到鼠标的前后2
个元素)
// 获取鼠标后面的一个元素
function getMouseAfterElement(children: HTMLElement[], event: EnhancedMouseEvent, layout: `${LayoutEnum}`){
function isMouseAfter(element: HTMLElement) {
const { x, y } = event.originEvent
const { left, top } = getBoundingClientRect(element)
if (layout === LayoutEnum.HORIZONTAL) {
return x <= left
}
return y <= top
}
for (let i = 0; i < children.length; i++) {
const element = children[i]
if (isMouseAfter(element)) {
return element
}
}
}
// 获取鼠标前面的一个元素
function getMouseBeforeElement(children: HTMLElement[], event: EnhancedMouseEvent, layout: `${LayoutEnum}`){
function isMouseBefore(element: HTMLElement) {
const { x, y } = event.originEvent
const { left, top } = getBoundingClientRect(element)
if (layout === LayoutEnum.HORIZONTAL) {
// 水平方向
return x > left
}
// 垂直方向
return y > top
}
for (let i = children.length - 1; i >= 0; i--) {
const element = children[i]
if (isMouseBefore(element)) {
return element
}
}
}
const inThresholdRange = isInThresholdRange(event, thresholdSize)
const { container, children } = getElementChildrenAndContainer(event, inThresholdRange)
if (children.length <= 0) {
return {
event,
element,
direction: DirectionEnum.IN,
}
}
const firstElement = children[0]
const layout = calculateLayout(firstElement, container)
let afterMouseElement = getMouseAfterElement(children, event, layout)
let beforeMouseElement = getMouseBeforeElement(children, event, layout)
if (!afterMouseElement) afterMouseElement = beforeMouseElement
if (!beforeMouseElement) beforeMouseElement = afterMouseElement
内容2(根据鼠标的前后2个元素
得到最近的参考元素
和方向
)
function calculateXLocation(beforeElement: HTMLElement, afterElement: HTMLElement, event: EnhancedMouseEvent): AuxiliaryLineLocation {
const { x } = event.originEvent
const { left: afterElementLeft, right: afterElementRight, width: afterElementWidth } = getBoundingClientRect(afterElement)
const { left: beforeElementLeft, right: beforeElementRight, width: beforeElementWidth } = getBoundingClientRect(beforeElement)
const xIsInAfterElementRange = x >= afterElementLeft && x <= afterElementRight
const xIsInBeforeElementRange = x >= beforeElementLeft && x <= beforeElementRight
if (xIsInAfterElementRange) {
const isInLeftRange = x - (afterElementLeft + afterElementWidth / 2) < 0
return {
event,
element: afterElement,
direction: isInLeftRange ? DirectionEnum.LEFT : DirectionEnum.RIGHT,
}
}
if (xIsInBeforeElementRange) {
const isInLeftRange = x - (beforeElementLeft + beforeElementWidth / 2) < 0
return {
event,
element: beforeElement,
direction: isInLeftRange ? DirectionEnum.LEFT : DirectionEnum.RIGHT,
}
}
// 鼠标在二个元素中间
// 是否偏向beforeElement
const isDeviationBeforeElement = Math.abs(x - beforeElementRight) - Math.abs(x - afterElementLeft) < 0
return {
event,
element: isDeviationBeforeElement ? beforeElement : afterElement,
direction: isDeviationBeforeElement ? DirectionEnum.RIGHT : DirectionEnum.LEFT,
}
}
function calculateYLocation(beforeElement: HTMLElement, afterElement: HTMLElement, event: EnhancedMouseEvent): AuxiliaryLineLocation {
const { y } = event.originEvent
const { top: afterElementTop, bottom: afterElementBottom, height: afterElementHeight } = getBoundingClientRect(afterElement)
const { top: beforeElementTop, bottom: beforeElementBottom, height: beforeElementHeight } = getBoundingClientRect(beforeElement)
const yIsInAfterElementRange = y >= afterElementTop && y <= afterElementBottom // 鼠标在后面元素范围内
const yIsInBeforeElementRange = y >= beforeElementTop && y <= beforeElementBottom // 鼠标在前面元素范围
if (yIsInAfterElementRange) {
const isInTopRange = y - (afterElementTop + afterElementHeight / 2) < 0
return {
event,
element: afterElement,
direction: isInTopRange ? DirectionEnum.TOP : DirectionEnum.BOTTOM,
}
}
if (yIsInBeforeElementRange) {
const isInToptRange = y - (beforeElementTop + beforeElementHeight / 2) < 0
return {
event,
element: beforeElement,
direction: isInToptRange ? DirectionEnum.TOP : DirectionEnum.BOTTOM,
}
}
// 鼠标在二个元素中间
// 是否偏向beforeElement
const isDeviationBeforeElement = Math.abs(y - beforeElementBottom) - Math.abs(y - afterElementTop) < 0
return {
event,
element: isDeviationBeforeElement ? beforeElement : afterElement,
direction: isDeviationBeforeElement ? DirectionEnum.BOTTOM : DirectionEnum.TOP,
}
}
...
let afterMouseElement = getMouseAfterElement(children, event, layout)
let beforeMouseElement = getMouseBeforeElement(children, event, layout)
if (!afterMouseElement) afterMouseElement = beforeMouseElement
if (!beforeMouseElement) beforeMouseElement = afterMouseElement
return layout === LayoutEnum.HORIZONTAL
? calculateXLocation(beforeMouseElement!, afterMouseElement!, event)
: calculateYLocation(beforeMouseElement!, afterMouseElement!, event)
这里我要说明一下calculateXLocation
方法(calculateYLocation
方法是一样的思路),实际上只做了3件事情
- 鼠标是不是在
鼠标前一个元素范围内
,如果在的话,看鼠标在元素的左侧还是右侧
,返回方向和参考元素 - 鼠标是不是在
鼠标后一个元素范围内
,如果在的话,看鼠标在元素的左侧还是右侧
,返回方向和参考元素 - 如果鼠标不在
前后2个元素范围内
,那就看鼠标离哪个元素更近
,返回方向和参考元素
绘制辅助线
上面我们计算好了计算方向和参考元素
,我们归纳一下代码
export function auxiliaryLinePlugin(options: AuxiliaryLinePluginOptions = {}) {
return function ({ context }: DragDropPluginCtx) {
const {
onEnd,
onMove,
onStart,
onRender,
thresholdSize = 8,
auxiliaryLineSize = 2,
} = options
const dropableRef = context.useCanDropable()
const auxiliaryLineLocationRef = ref<AuxiliaryLineLocation>()
function calculateAuxiliaryLineLocation(event: EnhancedMouseEvent): AuxiliaryLineLocation {
const element = getTargetElementByEvent(event)
const inThresholdRange = isInThresholdRange(element, event, thresholdSize)
const { container, children } = getElementChildrenAndContainer(element, inThresholdRange)
if (children.length <= 0) {
return {
event,
element,
direction: DirectionEnum.IN,
}
}
const firstElement = children[0]
const layout = calculateLayout(firstElement, container)
let afterMouseElement = getMouseAfterElement(children, event, layout)
let beforeMouseElement = getMouseBeforeElement(children, event, layout)
if (!afterMouseElement) afterMouseElement = beforeMouseElement
if (!beforeMouseElement) beforeMouseElement = afterMouseElement
return layout === LayoutEnum.HORIZONTAL
? calculateXLocation(beforeMouseElement!, afterMouseElement!, event)
: calculateYLocation(beforeMouseElement!, afterMouseElement!, event)
}
context.onStart((event) => {
onStart?.(event)
})
context.onMove((event) => {
if (!unref(dropableRef)) return
const auxiliaryLineLocation = calculateAuxiliaryLineLocation(event)
auxiliaryLineLocationRef.value = auxiliaryLineLocation
onMove?.(event)
})
context.onEnd((event) => {
onEnd?.(event)
auxiliaryLineLocationRef.value = undefined
})
return () => {
return <div>1123123</div>
}
}
}
方向和参考元素
我们都有了,根据这些参数我们可以很简单的绘制出来我们的辅助线,代码如下
export function auxiliaryLinePlugin(options: AuxiliaryLinePluginOptions = {}) {
return function ({ context }: DragDropPluginCtx) {
const auxiliaryLineLocationRef = ref<AuxiliaryLineLocation>()
...
const styleGetter = computed(() => {
const location = unref(auxiliaryLineLocationRef)
const basicStyle: CSSProperties = {
position: 'fixed',
background: '#0071e7',
pointerEvents: 'none',
zIndex: 'auto',
}
if (location && location.direction !== DirectionEnum.IN) {
const { direction, element, event } = location
let { left, top, right, bottom, width, height } = getBoundingClientRect(element)
if (event.inIframe) {
const { left: offsetLeft, top: offsetTop } = getBoundingClientRect(event.iframe)
left += offsetLeft
right += offsetLeft
top += offsetTop
bottom += offsetTop
}
const rectStyle = {
[DirectionEnum.LEFT]: { width: numberToPx(auxiliaryLineSize) },
[DirectionEnum.TOP]: { height: numberToPx(auxiliaryLineSize) },
[DirectionEnum.RIGHT]: { width: numberToPx(auxiliaryLineSize), left: numberToPx(right) },
[DirectionEnum.BOTTOM]: { height: numberToPx(auxiliaryLineSize), top: numberToPx(bottom) },
}[direction]
const r = {
...{
left: numberToPx(left),
top: numberToPx(top),
width: numberToPx(width),
height: numberToPx(height),
...rectStyle,
},
...basicStyle,
}
return r
}
return basicStyle
})
return () => {
const style = unref(styleGetter)
const location = unref(auxiliaryLineLocationRef)
if(onRender){
// 用户自定义渲染
return onRender(location,style)
}
if (!location || location.direction === DirectionEnum.IN){
return null
}
return <div style={style}></div>
}
}
}
结语
如有错误之处,请指正,谢谢大家~,实际代码还做了一些扩展性的参数,本文为了讲解内容就没有说明这些参数了