可拖拽、缩放、旋转组件之 - 多元素组合与拆分功能

🌈介绍

基于 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>

12.png

元素组合与拆分

选中区域

组合前,我们需要选中需要组合的元素,类似下图这样的效果

13.gif

单独抽离区域选中组件 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 获取的是画布元素

  1. 首先,将 show 的值设置为 true,以显示选中区域,获取鼠标按下的位置:通过鼠标事件对象 e 获取鼠标按下时的页面上的横坐标 downX 和纵坐标 downY。

  2. 获取画布的位置,从而计算选中区域的相对于画布的偏移量

  3. 在 onMouseMove 函数中计算区域的大小和位置:通过鼠标移动的距离 disX 和 disY 计算区域的宽度和高度,并根据移动的方向调整 left 和 top 的值,从而实现编辑区域的调整。

  • 宽度和高度直接取各自移动距离的绝对值
  • 如果 disX 为负数则left要减去增加的宽度,dixY同理
  1. 抬起鼠标 onMouseUp 中隐藏选区,重置选区数据

  2. 在 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>

步骤解析

  1. 给画布注册mousedown事件 onEditorMouseDown,如果已有选中的元素将其全部设置为非选状态,并且不触发这个区域选择事件,只有画布上没有选中的元素时触发区域的mousedown

  2. 调用刚刚封装 Area 组件的 onMouseDown 方法并传入了事件对象,因此在在 Area 组件里的 onMouseDown 的 e.target 其实获取的是画布元素

  3. 监听 Area 组件的 move 事件 onAreaMove。当选区在 Area 组件中移动时,onAreaMove 会被触发。在该函数中,根据选区的数据去判断是否有元素在选区内。如果有元素在选区内,就将它们设置为选中状态。

  4. 判断元素是否在选区内的逻辑还是挺好理解的。对于每个元素,判断选区的 left 是否小于元素的 left,且选区的 left + width 是否大于元素的 left + width。类似地,对于 top 也进行类似的判断。只有当元素的左上角和右下角同时在选区内,才判定该元素为被选中状态。

移动选中的元素

移动多个区域选中的元素,类似下面的效果

14.gif

要计算每个元素的移动距离,就需要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]
  })
}
  1. change 事件:change 事件主要用于更新最新的拖拽数据(dragData)

  2. drag-start 事件:

  • 检查是否是区域选择状态(areaSelected.value),如果不是区域选择状态,则将所有选中的元素的 selected 属性设置为 false,即将它们设为非选中状态。
  • 选中当前元素(即 current)并记录其初始 lefttop 位置到 extraDragData 中,以便后续计算多个选中元素的移动距离。
  1. drag 事件:
  • 通过当前拖拽的 dragDataextraDragData 中记录的初始位置,计算出拖拽元素的移动距离 disXdisY
  • 循环遍历所有元素,对于选中的元素(除了当前拖拽元素),更新其 lefttop 位置,以实现多选元素的联动移动。
  • 更新 extraDragData 中的 prevLeftprevTop,以便下一次计算移动距离。

上面多了 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

组合与拆分

15.gif

完成上面的工作后,我们来看看如何将多个元素组合成一个,为了方便渲染我们先封装一个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]
}

下面分别解释这两个函数

  1. 组合元素 (makeGroup 函数):

    • 首先,获取所有选中的元素 (selectedElements)。
    • 如果没有选中的元素,则直接返回,不执行组合操作。
    • 对于选中的元素,遍历计算它们的最小 lefttop 值,以及最大 lefttop 值,从而确定组合后元素的位置和尺寸。
    • 然后,遍历选中的元素,根据计算得到的最小 lefttop,更新它们的 lefttop 值,使它们相对于组合后元素的位置发生偏移,从而将它们归置到组合后元素的内部。
    • 创建一个名为 groupElement 的新元素,作为组合后的元素。该元素的属性包括:component 设置为 ‘es-group’,group 设置为 true,以及通过计算得到的 dragData 信息和选中的元素列表 selectedElements
    • 将组合后的元素 groupElement 添加到 data.value.componentList 中,同时保留其他非选中元素。
  2. 取消组合 (cancelGroup 函数):

    • 首先,检查当前选中的元素是否为一个组合元素(current.component === 'es-group')。如果不是组合元素,直接返回,不执行拆分操作。
    • 获取组合元素 currentprops.elements,该属性存储了组合元素内部的所有元素列表。
    • 对于组合元素内部的每个元素,计算其新的 lefttop 值,使它们相对于画布发生偏移,并考虑了组合元素的位置和角度。
    • 创建一个新的元素列表 newElements,该列表包含了拆分后的所有元素。
    • 将组合元素 currentdata.value.componentList 中删除,同时将拆分后的元素列表 newElements 添加到 data.value.componentList 中。

最后

本节只是对多个元素的组合与拆分的简单实现,对于组合后的旋转与缩放我想在后面的文章中介绍。

最后来看看在drawio中元素组合与拆分的效果

16.gif

drawio在实现组合后缩放会有一点小问题,大家看下图

17.gif

当然我们的目标是尽可能实现理想的组合后的缩放与旋转

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值