可拖拽、缩放、旋转组件之 - 多元素组合与拆分功能
🌈介绍
基于 vue3.x + CompositionAPI + typescript + vite 的可拖拽、缩放、旋转的组件
- 拖拽&区域拖拽
- 支持缩放
- 旋转
- 网格拖拽缩放
这节主要来分享如何使用es-drager,根据现有功能实现多个元素组合与拆分功能
es-drager的更新
es-drager 的1.x版本支持移动端啦
另外最近还在使用es-drager开发一个低代码编辑器(还未成型),也算是一个es-drager的综合使用案例吧,老铁们可以先到 编辑器案例 中查看
本章内容
- 使用svg绘制网格
- 元素组合与拆分
使用svg绘制网格
在开始讲组合之前,先来介绍一下如何使用svg画一个指定大小的网格。前面的demo都是使用css的方式,感觉还是不太灵活,有一定的局限性
这里直接抽离成了一个 vue 组件
<template>
<div class="grid-rect" :style="rectStyle">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern v-if="showSmall" id="smallGrid" :width="grid" :height="grid" patternUnits="userSpaceOnUse">
<path :d="`M ${grid} 0 L 0 0 0 ${grid}`" fill="none" :stroke="color.grid" stroke-width="0.5"/>
</pattern>
<pattern id="grid" :width="bigGrid" :height="bigGrid" patternUnits="userSpaceOnUse">
<rect v-if="showSmall" :width="bigGrid" :height="bigGrid" fill="url(#smallGrid)"/>
<path :d="`M ${bigGrid} 0 L 0 0 0 ${bigGrid}`" fill="none" :stroke="color.bigGrid" stroke-width="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
</div>
</template>
<script setup lang='ts'>
import { computed } from 'vue'
import { useAppStore } from '@/store'
const store = useAppStore()
const props = defineProps({
grid: { // 小网格的大小
type: Number,
default: 10
},
gridCount: { // 小网格的数量,默认为5个
type: Number,
default: 5
},
showSmall: { // 是否显示小网格
type: Boolean,
default: true
}
})
// 计算大网格的大小
const bigGrid = computed(() => props.grid * props.gridCount)
// 处理网站皮肤,可忽略
const color = computed(() => {
const colors = [['#e4e7ed', '#ebeef5'], ['#414243', '#363637']]
const [bigGrid, grid] = colors[store.isLight ? 0 : 1]
return { bigGrid, grid }
})
const rectStyle = computed(() => ({ '--border-color': color.value.bigGrid }))
</script>
<style lang='scss' scoped>
.grid-rect {
width: 100%;
height: 100%;
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
</style>
可以看到,如果不加属性的话,整个网格组件还是挺简单的
-
在
<defs>
标签中定义了两个图案(pattern)元素。<pattern>
用于创建可重复使用的图案。这里定义了两个图案,一个是名为"smallGrid"的小网格图案,另一个是名为"grid"的大网格图案。 -
小网格的 id 为 smallGrid,它的大小默认是 grid=10
-
大网格的 id 为 grid,默认大小 grid*gridCount=50,由一个矩形(
<rect>
)和一个路径(<path>
)组成。矩形用于填充整个图案区域,其填充样式(fill)使用了名为"smallGrid"的小网格图案。路径用于创建四条边框线,从起点(50, 0)到(0, 0),再到(0, 50)。 -
最后,通过
<rect>
元素创建一个矩形,它的宽度和高度都设置为100%,填充样式(fill)使用了名为"grid"的大网格图案。
使用时直接包裹在画布元素里即可,当然我们也可以传入指定网格的大小
<template>
<div class="es-editor">
<GridRect />
</div>
</template>
<script setup lang='ts'>
import GridRect from '@/components/editor/GridRect.vue'
</script>
<style lang='scss' scoped>
.es-editor {
position: relative;
width: 800px;
height: 600px;
}
</style>
元素组合与拆分
选中区域
组合前,我们需要选中需要组合的元素,类似下图这样的效果
单独抽离区域选中组件 Area
<template>
<div v-show="show" class="es-editor-area" :style="areaStyle"></div>
</template>
<script setup lang='ts'>
import { computed, ref } from 'vue'
const emit = defineEmits(['move', 'up'])
const show = ref(false)
const areaData = ref({
width: 0,
height: 0,
top: 0,
left: 0
})
const areaStyle = computed(()=> {
const { width, height, top, left } = areaData.value
return {
width: width + 'px',
height: height + 'px',
top: top + 'px',
left: left + 'px'
}
})
function onMouseDown(e: MouseEvent) {
show.value = true
// 鼠标按下的位置
const { pageX: downX, pageY: downY } = e;
const elRect = (e.target as HTMLElement)!.getBoundingClientRect()
// 鼠标在编辑器中的偏移量
const offsetX = downX - elRect.left
const offsetY = downY - elRect.top
const onMouseMove = (e: MouseEvent) => {
// 移动的距离
const disX = e.pageX - downX
const disY = e.pageY - downY
// 得到默认的left、top
let left = offsetX, top = offsetY
// 宽高取鼠标移动距离的绝对值
let width = Math.abs(disX), height = Math.abs(disY)
// 如果往左,将left减去增加的宽度
if (disX < 0) {
left = offsetX - width
}
// 如果往上,将top减去增加的高度
if (disY < 0) {
top = offsetY - height
}
areaData.value = {
width,
height,
left,
top
}
emit('move', { ...areaData.value })
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
show.value = false
areaData.value = {
width: 0,
height: 0,
top: 0,
left: 0
}
emit('up', areaData.value)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
defineExpose({
onMouseDown,
areaData
})
</script>
注意:由于这个onMouseDown是画布触发时调用的, 因此
e.target
获取的是画布元素
-
首先,将 show 的值设置为 true,以显示选中区域,获取鼠标按下的位置:通过鼠标事件对象 e 获取鼠标按下时的页面上的横坐标 downX 和纵坐标 downY。
-
获取画布的位置,从而计算选中区域的相对于画布的偏移量
-
在 onMouseMove 函数中计算区域的大小和位置:通过鼠标移动的距离 disX 和 disY 计算区域的宽度和高度,并根据移动的方向调整 left 和 top 的值,从而实现编辑区域的调整。
- 宽度和高度直接取各自移动距离的绝对值
- 如果 disX 为负数则left要减去增加的宽度,dixY同理
-
抬起鼠标 onMouseUp 中隐藏选区,重置选区数据
-
在 onMouseMove 和 onMouseUp 中都触发了相应的事件 move和up并传递零零选区的数据信息
有了这个组件,该如何使用呢?
先上使用代码,后面有详细解释
<template>
<div class="es-container">
<div class="es-tools">
<el-button type="primary">组合</el-button>
<el-button type="primary">拆分</el-button>
</div>
<div class="es-editor" @mousedown="onEditorMouseDown">
<Drager
v-for="item in data.componentList"
v-bind="item"
@click.stop
@mousedown.stop
>
<component :is="item.component!">{{ item.text }}</component>
</Drager>
<GridRect />
<Area ref="areaRef" @move="onAreaMove" />
</div>
</div>
</template>
<script setup lang='ts'>
import { ref } from 'vue'
import GridRect from '@/components/editor/GridRect.vue'
import Drager, { DragData } from 'es-drager'
import Area from '@/components/editor/Area.vue'
import { ComponentType } from '@/components/types'
interface EditorState {
componentList: ComponentType[]
}
const data = ref<EditorState>({
componentList: [
{
component: 'div',
text: 'div1',
width: 100,
height: 100,
left: 100,
top: 100
},
{
component: 'div',
text: 'div2',
width: 100,
height: 100,
top: 200,
left: 300
}
]
})
const areaRef = ref()
// 编辑器鼠标按下事件
function onEditorMouseDown(e: MouseEvent) {
let flag = false
data.value.componentList.forEach((item: ComponentType) => {
// 如果有选中的元素,取消选中
if (item.selected) {
item.selected = false
flag = true
}
})
if (!flag) {
areaRef.value.onMouseDown(e)
}
}
function onAreaMove(areaData: DragData) {
for (let i = 0; i < data.value.componentList.length; i++) {
const item = data.value.componentList[i] as Required<ComponentType>
// 包含left
const containLeft = areaData.left < item.left && areaData.left + areaData.width > item.left + item.width
// 包含top
const containTop = areaData.top < item.top && areaData.top + areaData.height > item.top + item.height
if (containLeft && containTop) {
item.selected = true
} else {
item.selected = false
}
}
}
</script>
步骤解析
-
给画布注册mousedown事件
onEditorMouseDown
,如果已有选中的元素将其全部设置为非选状态,并且不触发这个区域选择事件,只有画布上没有选中的元素时触发区域的mousedown -
调用刚刚封装 Area 组件的 onMouseDown 方法并传入了事件对象,因此在在 Area 组件里的 onMouseDown 的
e.target
其实获取的是画布元素 -
监听 Area 组件的
move
事件onAreaMove
。当选区在 Area 组件中移动时,onAreaMove
会被触发。在该函数中,根据选区的数据去判断是否有元素在选区内。如果有元素在选区内,就将它们设置为选中状态。 -
判断元素是否在选区内的逻辑还是挺好理解的。对于每个元素,判断选区的
left
是否小于元素的left
,且选区的left + width
是否大于元素的left + width
。类似地,对于top
也进行类似的判断。只有当元素的左上角和右下角同时在选区内,才判定该元素为被选中状态。
移动选中的元素
移动多个区域选中的元素,类似下面的效果
要计算每个元素的移动距离,就需要es-drager提供的一些事件了
<Drager
v-for="item, index in data.componentList"
v-bind="item"
@change="onChange($event, item)"
@drag-start="onDragstart(index)"
@drag="onDrag"
@click.stop
@mousedown.stop
>
<component :is="item.component!">{{ item.text }}</component>
</Drager>
const currentIndex = ref(-1)
const areaRef = ref()
// 每次拖拽移动的距离
const areaSelected = ref(false)
const extraDragData = ref({
prevLeft: 0,
prevTop: 0
})
function onDragstart(index: number) {
if (!areaSelected.value) { // 如果是区域选中状态
// 将上一次移动元素变为非选
data.value.componentList.forEach((item: ComponentType) => item.selected = false)
}
const current = data.value.componentList[index]
// 选中当前元素
current.selected = true
// 记录按下的数据,为了计算多个选中时移动的距离
extraDragData.value.prevLeft = current.left!
extraDragData.value.prevTop = current.top!
currentIndex.value = index
}
function onDrag(dragData: DragData) {
const disX = dragData.left - extraDragData.value.prevLeft
const disY = dragData.top - extraDragData.value.prevTop
// 如果选中了多个
data.value.componentList.forEach((item: ComponentType, index: number) => {
if (item.selected && currentIndex.value !== index) {
item.left! += disX
item.top! += disY
}
})
extraDragData.value.prevLeft = dragData.left
extraDragData.value.prevTop = dragData.top
}
function onChange(dragData: DragData, item: ComponentType) {
Object.keys(dragData).forEach((key) => {
item[key as keyof DragData] = dragData[key as keyof DragData]
})
}
-
change
事件:change
事件主要用于更新最新的拖拽数据(dragData) -
drag-start
事件:
- 检查是否是区域选择状态(
areaSelected.value
),如果不是区域选择状态,则将所有选中的元素的selected
属性设置为false
,即将它们设为非选中状态。 - 选中当前元素(即
current
)并记录其初始left
和top
位置到extraDragData
中,以便后续计算多个选中元素的移动距离。
drag
事件:
- 通过当前拖拽的
dragData
和extraDragData
中记录的初始位置,计算出拖拽元素的移动距离disX
和disY
。 - 循环遍历所有元素,对于选中的元素(除了当前拖拽元素),更新其
left
和top
位置,以实现多选元素的联动移动。 - 更新
extraDragData
中的prevLeft
和prevTop
,以便下一次计算移动距离。
上面多了 areaSelected 记录是否是区域选择状态,那么在什么情况它的值才是true呢?
这时我们就要监听 Area 组件的 up 事件了
<Area ref="areaRef" @move="onAreaMove" @up="onAreaUp" />
// 松开区域选择
function onAreaUp() {
areaSelected.value = data.value.componentList.some((item: ComponentType) => item.selected)
// 如果区域有选中元素
if (areaSelected.value) {
setTimeout(() => {
document.addEventListener('click', () => {
areaSelected.value = false
}, { once: true })
})
}
}
只有区域选中了元素,areaSelected才能是true,然后点击其它区域是设置为false
组合与拆分
完成上面的工作后,我们来看看如何将多个元素组合成一个,为了方便渲染我们先封装一个Group组件
Group 组件
这个组件的功能就是循环显示所有组合的元素
<template>
<div class="es-group">
<component
v-for="item in list"
:is="item.component!"
v-bind="item.props"
:style="{
...item.style,
width: item.width + 'px',
height: item.height + 'px',
left: item.left + 'px',
top: item.top + 'px',
transform: `rotate(${item.angle || 0}deg)`,
position: 'absolute'
}"
>
{{ item.text }}
</component>
</div>
</template>
<script setup lang='ts'>
import { ComponentType } from '@/components/types'
import { PropType, computed } from 'vue'
const props = defineProps({
elements: {
type: Array as PropType<ComponentType[]>,
default: () => []
},
data: {
type: Object as PropType<ComponentType>,
default: () => ({})
}
})
const list = computed(() => {
return props.elements.map(item => {
return { ...item }
})
})
</script>
- 随后我们准备两个按钮,分别注册了makeGroup和cancelGroup点击事件
<el-button type="primary" @click="makeGroup">组合</el-button>
<el-button type="primary" @click="cancelGroup">拆分</el-button>
// 组合元素
function makeGroup() {
const selectedItems = data.value.componentList.filter(item => item.selected)
if (!selectedItems.length) return
// 设第一个元素的位置为最大和最小
let { left: minLeft, top: minTop } = selectedItems[0] as Required<ComponentType>
let maxLeft = minLeft, maxTop = minTop
Math.max(...selectedItems.map(item => item.left!))
selectedItems.slice(1).forEach(item => {
const { left, top, width, height } = item as Required<ComponentType>
// 最小left
minLeft = Math.min(minLeft, left)
// 最大top
maxLeft = Math.max(maxLeft, left + width)
// 最小top
minTop = Math.min(minTop, top)
// 最大top
maxTop = Math.max(maxTop, top + height)
})
selectedItems.forEach(item => {
item.left = item.left! - minLeft
item.top = item.top! - minTop
})
const dragData = {
left: minLeft,
top: minTop,
width: maxLeft - minLeft, // 宽度 = 最大left - 最小left
height: maxTop - minTop, // 高度 = 最大top - 最小top
}
const groupElement: ComponentType = {
component: 'es-group',
group: true,
...dragData,
props: {
elements: selectedItems
}
}
const newElements = data.value.componentList.filter(item => !item.selected)
data.value.componentList = [...newElements, groupElement]
}
// 取消组合
function cancelGroup() {
const current = data.value.componentList[currentIndex.value]
if (!current || current.component !== 'es-group') {
return
}
const items = current.props.elements as ComponentType[]
const newElements = items.map(item => {
return {
...item,
selected: false,
left: item.left! + current.left!,
top: item.top! + current.top!,
angle: (item.angle! || 0) + (current.angle! || 0),
}
})
const list = data.value.componentList.filter(item => item !== current)
data.value.componentList = [...list, ...newElements]
}
下面分别解释这两个函数
-
组合元素 (
makeGroup
函数):- 首先,获取所有选中的元素 (
selectedElements
)。 - 如果没有选中的元素,则直接返回,不执行组合操作。
- 对于选中的元素,遍历计算它们的最小
left
和top
值,以及最大left
和top
值,从而确定组合后元素的位置和尺寸。 - 然后,遍历选中的元素,根据计算得到的最小
left
和top
,更新它们的left
和top
值,使它们相对于组合后元素的位置发生偏移,从而将它们归置到组合后元素的内部。 - 创建一个名为
groupElement
的新元素,作为组合后的元素。该元素的属性包括:component
设置为 'es-group',group
设置为true
,以及通过计算得到的dragData
信息和选中的元素列表selectedElements
。 - 将组合后的元素
groupElement
添加到data.value.componentList
中,同时保留其他非选中元素。
- 首先,获取所有选中的元素 (
-
取消组合 (
cancelGroup
函数):- 首先,检查当前选中的元素是否为一个组合元素(
current.component === 'es-group'
)。如果不是组合元素,直接返回,不执行拆分操作。 - 获取组合元素
current
的props.elements
,该属性存储了组合元素内部的所有元素列表。 - 对于组合元素内部的每个元素,计算其新的
left
和top
值,使它们相对于画布发生偏移,并考虑了组合元素的位置和角度。 - 创建一个新的元素列表
newElements
,该列表包含了拆分后的所有元素。 - 将组合元素
current
从data.value.componentList
中删除,同时将拆分后的元素列表newElements
添加到data.value.componentList
中。
- 首先,检查当前选中的元素是否为一个组合元素(
最后
本节只是对多个元素的组合与拆分的简单实现,对于组合后的旋转与缩放我想在后面的文章中介绍。
最后来看看在drawio中元素组合与拆分的效果
drawio在实现组合后缩放会有一点小问题,大家看下图
当然我们的目标是尽可能实现理想的组合后的缩放与旋转
转载自:https://juejin.cn/post/7258337246024843319