乐趣区

关于前端:低代码可视化拖拽编辑器实现方案基于vue32-setup语法糖

前言

随着业务一直倒退,低代码、无代码平台越来越常见,它升高开发门槛、疾速响应业务需要、晋升开发效率。零开发教训的业务人员通过可视化拖拽等形式,即可疾速搭建各种利用。本文次要是解说低代码可视化拖拽平台前端展现层面的实现逻辑和计划,对于后端逻辑、数据库设计、以及自动化部署等临时没有波及。

编码程度个别,提供给小伙伴们一些思路或学习参考。源码地址

成果展现

展示区划分

首先咱们须要先清晰咱们要实现的 UI 展现成果,分为三局部(组件选项区、可视化展示区、元素配置编辑区)

1、组件选项区

1.1 数据格式定义

为了展现出各种元素,先定义元素的类型(文字、图片、按钮、banner、表单等等),具体数据格式如下,详情能够查看源码门路(src/config/template.ts、src/config/base.ts), 这些组件的每一项也能够存储在库,通过接口查问回来,只是这里没有实现。

  • template.ts: 定义所有类型自定义组件的配置

    export const config: any =  {
       text: [
         {
           config: {
             name: 'content-box',
             noDrag: 1,
             slot: [
               {
                 name: 'content-input',
                 style: {backgroundImage: require('@/assets/title1-left-icon.png'),
                   backgroundRepeat: 'no-repeat',
                   backgroundSize: 'contain',
                   borderWidth: 0,
                   fontSize: '14px',
                   height: '13px',
                   lineHeight: '32px',
                   width: '18px'
                 },
                 value: ''
               },
               {
                 name: 'content-input',
                 style: {
                   height: '32px',
                   paddingLeft: '5px',
                   paddingRight: '5px'
                 },
                 value: "<div style=\"line-height: 2;\"><span style=\"font-size: 16px; color: #fce7b6;\"><strong> 流动规定 </strong></span></div>"
               },
               {
                 name: 'content-input',
                 style: {backgroundImage: require('@/assets/title1-right-icon.png'),
                   backgroundRepeat: 'no-repeat',
                   backgroundSize: 'contain',
                   borderWidth: 0,
                   fontSize: '14px',
                   height: '13px',
                   lineHeight: '32px',
                   marginRight: '5px',
                   width: '18px'
                 },
                 value: ''
               }
             ],
             style: {
               alignItems: 'center',
               backgroundColor: 'rgba(26, 96, 175, 1)',
               display: 'flex',
               height: '40px',
               justifyContent: 'center',
               paddingLeft: '1px'
             },
             value: ''
           },
           name: '带点的题目',
           preview: require('@/assets/title1.jpg')
         }
       ],
       img: [
         {
           config: {value: require('@/assets/gift.png'),
             name: 'content-asset',
             style: {
               width: '100px',
               height: '100px',
               display: 'inline-block'
             }
           },
           preview: require('@/assets/gift.png'),
           name: '礼包'
         }
       ],
       btn: [....],
       form: [...]
     }
  • base.ts: 中定义根本组件的配置

    export const config: any = {
      text: {
         value: '<div style="text-align: center; line-height: 1;"><span style="font-size: 14px; color: #333333;"> 这是一行文字 </span></div>',
         style: {},
         name: 'content-input'
       },
       multipleText: {
         value: '<div style="text-align: center; line-height: 1.5;"><span style="font-size: 14px; color: #333333;"> 这是多行文字 <br /> 这是多行文字 <br /> 这是多行文字 <br /></span></div>',
         name: 'content-input',
         style: {}},
       img: {value: require('@/assets/logo.png'),
         name: 'content-asset',
         style: {
           width: '100px',
           height: '100px',
           display: 'inline-block'
         }
       },
       box: {
         name: 'content-box',
         noDrag: 0,
         style: {
           width: '100%',
           minHeight: '100px',
           height: 'auto',
           display: 'inline-block',
           boxSizing: 'border-box'
         },
         slot: []}
     }

    根本元素(文字 content-input、图片 content-asset)次要蕴含以下属性: name(组件名称)、style(行内款式)、value(内容值)

盒子元素(content-box)次要蕴含以下属性: name(组件名称)、style(行内款式)、noDrag(是否可拖拽)、slot(插槽内容

1.2 实现可拖拽

为了实现可拖拽成果,这里应用了 sortable.js 拖拽库来实现。更多应用细节可查看官网文档
要害实现代码如下:

// 左侧选项区 DOM 构造
<el-tabs tab-position="left" class="tabs-list" v-model="activeType">
  <el-tab-pane v-for="item in props.tabConfig" :key="item.value" :label="item.label" :name="item.value">
    <template #label>
      <span class="tabs-list-item">
        <i :class="`iconfont ${item.icon}`"></i>
        <span>{{item.label}}</span>
      </span>
    </template>
    <div class="tab-content">
      <div class="tab-content-title">{{item.label}}</div>
      <div class="main-box" ref="mainBox">
        <div class="config-item base" v-if="activeType ==='base'"data-name="text"@click="addToSubPage(Base.config['text'])">
          <el-icon :size="20"><Document /></el-icon>
          <div> 文本 </div>
        </div>
        <div class="config-item base" v-if="activeType ==='base'"data-name="box"@click="addToSubPage(Base.config['box'])">
          <el-icon :size="20"><Box /></el-icon>
          <div> 盒子 </div>
        </div>
        <div class="config-item" v-for="_item in item.children" :key="_item" :data-name="_item" @click="addToSubPage(Base.config[_item])">
          <div v-if="activeType ==='text'"class="config-item-text"v-html="Base.config[_item].value"></div>
          <img v-if="activeType ==='img'"class="config-item-img":src="Base.config[_item].value"/>
        </div>
        <div class="config-item" v-for="(tItem, tIndex) in Template.config[activeType]" :key="tItem.id" :data-type="activeType" :data-index="tIndex" @click="addToSubPage(tItem.config)">
          <img :src="tItem.preview" class="preview">
        </div>
      </div>
    </div>
  </el-tab-pane>
</el-tabs>
const mainBox = ref()
const initSortableSide = (): void => {
  // 获取 mainBox 下每一个元素,遍历并注册拖拽组
  Array.from(mainBox.value).forEach(($box, index) => {instance[`_sortable_${index}`] && instance[`_sortable_${index}`].destroy()
    instance[`_sortable_${index}`] = Sortable.create($box, {
      filter: '.ignore', // 须要过滤或疏忽指定元素
      sort: false, // 不容许组内排序
      group: {
        name: 'shared', // 自定义组名
        pull: 'clone', // 从以后组克隆拖出
        put: false, // 不容许拖入
      },
      // 开始拖拽回调函数
      onStart: () => {
        // 给 subpage 展示区增加选中框款式
       (document.querySelector('.subpage') as HTMLElement).classList.add('active')
      },
      // 完结拖拽回调函数
      onEnd: ({item, originalEvent}: any) => {...}
    })
  })
}

这里次要讲一下 onEnd 外面的逻辑,当拖拽组件并将其挪动到两头的可视化展示区的时候,须要做以下 2 个要害操作。

  • 判断是否拖拽到可视化展现区内
  • 获取以后拖拽元素的配置,并更新 pinia 中 store 的值。(pinia 是 vue 新一代状态治理插件,能够认为是 vuex5.)
onEnd: ({item, originalEvent}: any) => {
      // 获取鼠标放开后的 X、Y 坐标
    const {pageX, pageY} = originalEvent
    // 获取可视化展示区的上下左右坐标
    const {left, right, top, bottom} = (document.querySelector('.subpage') as HTMLElement).getBoundingClientRect()
    const {dataset} = item
    // 为了移除被 clone 到可视化区的 dom 构造,通过配置来渲染可视化区的内容
    if ((document.querySelector('.subpage') as HTMLElement).contains(item)) {item.remove()
    }
      // 编辑判断
    if (pageX > left && pageX  < right && pageY > top && pageY < bottom) {
      // 获取自定义属性中的 name、type、index
      const {name, type, index} = dataset
      let currConfigItem = {} as any
      // 若存在 type 阐明不是根底类型, 在 template.ts 找到对应的配置。if (type) {currConfigItem = utils.cloneDeep(Template.config[type][index].config)
        // 应用 nanoid 生成惟一 id
        currConfigItem.id = utils.nanoid()
        // 递归遍历组件外部的 slot,为每个元素增加惟一 id
        currConfigItem.slot = configItemAddId(currConfigItem.slot)
      } else {
        // 根底类型操作
        currConfigItem = utils.cloneDeep(Base.config[name])
        currConfigItem.id = utils.nanoid()}
      // 批改 pinia 的 store 数据
      templateStore.config.push(currConfigItem)
      // 触发更新(通过 watch 实现)key.value = Date.now()} else {console.log('false')
    }
      // 移除两头可视化区选中款式
    (document.querySelector('.subpage') as HTMLElement).classList.remove('active')
  }

2、可视化展示区

两头的可视化展示区的性能次要是提供用户对具体元素选中以及拖拽操作。因而次要实现 元素展现 选中框 以及 可拖拽 性能。

2.1 元素展现

元素展现比较简单,只须要通过遍历 pinia 的 store 中的页面配置 config,并用动静组件 component 标签展现即可

<component v-for="item in template.config" :key="item.id" :is="item.name" :config="item" :id="item.id">
</component>
2.2 实现选中框

实现选中框的逻辑绝对简单一点,其中要害的两个事件是 hover(鼠标悬浮在元素上)和 select(鼠标点击元素)。

定义一个响应式对象来存储它们的变动状况:

const catcher: any = reactive(
  {
    hover: {
      id: '', // 元素 id
      rect: {}, // 元素坐标
      eleName: '' // 元素类名
    },
    select: {
      id: '',
      rect: {},
      eleName: ''
    }
  }
)

定义事件监听器(mouseover、click)

import {onMounted, ref} from 'vue'
const subpage = ref()

const listeners = {mouseover: (e: any) => {
    // findUpwardElement 办法为向上查找最近的指标元素
    const $el = utils.findUpwardElement(e.target, editorElements, 'classList')
    if ($el) {
      catcher.hover.id = $el.id
      // 重置 catcher 响应式对象
      resetRect($el, 'hover')
    } else {
      catcher.hover.rect.width = 0
      catcher.hover.id = ''
    }
  },
  click: (e: any) => {const $el = utils.findUpwardElement(e.target, editorElements, 'classList')
    if ($el) {
      template.activeElemId = $el.id
      catcher.select.id = $el.id
      resetRect($el, 'select')
    } else if (!utils.findUpwardElement(e.target, ['mouse-catcher'], 'classList')) {removeSelect()
    }
  }
} as any

onMounted(() => {Object.keys(listeners).forEach(event => {subpage.value.addEventListener(event, listeners[event], true)
  })
})

定义批改 catcher 响应式对象办法

interface rectInter {
  width: number;
  height: number;
  top: number;
  left: number;
}

// 批改 catcher 对象办法
const resetRect = ($el: HTMLElement, type: string): void => {if ($el) {const parentRect = utils.pick(subpage.value.getBoundingClientRect(), 'left', 'top')
    const rect: rectInter = utils.pick($el.getBoundingClientRect(), 'width', 'height', 'left', 'top')
    rect.left -= parentRect.left
    rect.top -= parentRect.top
    catcher[type].rect = rect
    catcher[type].eleName = $el.className
  }
}

const removeSelect = (): void => {
  catcher.select.rect.width = 0
  catcher.select.id = ''
  catcher.hover.rect.width = 0
  catcher.hover.id = ''template.activeElemId =''
}

// 重置 select 配置
const resetSelectRect = (id: string): void => {if (id) {resetRect(document.getElementById(id) as HTMLElement, 'select')
  } else {removeSelect()
  }
}

选中框组件

选中框组件包含选中框主体(通过不同色彩辨别盒子还是元素)、性能栏(高低挪动、删除、复制)。

// 将 catcher 对象传入组件
<MouseCatcher class="ignore" v-model="catcher"></MouseCatcher>

比拟要害的点是在操作性能栏的时候对全局配置的批改,具体逻辑能够查看源码(src/components/mouse-catcher/index.vue)

2.3 实现可视区拖拽

接下来是实现可视化展示区的可拖拽,这个区域与选项区不同,它容许外部元素的排序以及拖到别的拖拽组(盒子)。

要害逻辑如下:(次要剖析 onEnd 回调中的逻辑)

const initSortableSubpage = (): void => {instance._sortableSubpage && instance._sortableSubpage.destroy()
  instance._sortableSubpage = Sortable.create(document.querySelector('.subpage'), {
    group: 'shared',
    filter: '.ignore',
    onStart: ({item}: any) => {console.log(item.id)
    },
    onEnd: (obj: any) => {let { newIndex, oldIndex, originalEvent, item, to} = obj
      // 在可视区盒子内拖拽
      if (to.classList.contains('subpage')) {const { pageX} = originalEvent
        const {left, right} = (document.querySelector('.subpage') as HTMLElement).getBoundingClientRect()
        // 判断是否移出可视区
        if (pageX < left || pageX > right) {
          // 移出可视区,则移除元素
          templateStore.config.splice(oldIndex, 1)
        } else {
          // 判断挪动地位产生更改
          if (newIndex !== oldIndex) {
            // 新的地位在最初一位,须要减 1
            if (newIndex === templateStore.config.length) {newIndex = newIndex - 1}
             // 旧的地位在最初一位,须要减 1
            if (oldIndex === templateStore.config.length) {oldIndex = oldIndex - 1}
            // 数据调换地位
            const oldVal = utils.cloneDeep(templateStore.config[oldIndex])
            const newVal = utils.cloneDeep(templateStore.config[newIndex])
            utils.fill(templateStore.config, oldVal, newIndex, newIndex + 1)
            utils.fill(templateStore.config, newVal, oldIndex, oldIndex + 1)
          }
        }
      } else { // 若将元素挪动至其余拖拽组(盒子)const itemIndex = templateStore.config.findIndex((x: any) => x.id === item.id)
        const currContentBox = utils.findConfig(templateStore.config, to.id)
        const currItem = templateStore.config.splice(itemIndex, 1)[0]
        currContentBox.slot.push(currItem)
      }
    }
  })
}
2.4 实现盒子内拖拽

这里须要留神须要筛选可视区盒子 subpage 中类名为 content-box,并且不蕴含类名为 no-drag 的。

其要害逻辑也是在 onEnd 回调函数里,须要辨别元素在以后盒子外部挪动、元素挪动到其余盒子、元素挪动到可视区(subpage)盒子三种状况。

const initSortableContentBox = () => {console.log(Array.from(document.querySelectorAll('.subpage .content-box')).filter((x: any) => !x.classList.contains('no-drag')))
  Array.from(document.querySelectorAll('.subpage .content-box')).filter((x: any) => !x.classList.contains('no-drag')).forEach(($content, contentIndex) => {instance[`_sortableContentBox_${contentIndex}`] && instance[`_sortableContentBox_${contentIndex}`].destroy()
    instance[`_sortableContentBox_${contentIndex}`] = Sortable.create($content, {
      group: 'shared',
      onStart: ({from}: any) => {console.log(from.id)
      },
      onEnd: (obj: any) => {let { newIndex, oldIndex, item, to, from} = obj
        if (to.classList.contains('subpage')) { // 元素挪动至可视区盒子
          const currContentBox = utils.findConfig(templateStore.config, from.id)
          const currItemIndex = currContentBox.slot.findIndex((x: any) => x.id === item.id)
          const currItem = currContentBox.slot.splice(currItemIndex, 1)[0]
          templateStore.config.push(currItem)
        } else {if (from.id === to.id) {
             // 同一盒子中挪动
            const currContentBox = utils.findConfig(templateStore.config, from.id)
            if (newIndex !== oldIndex) {if (newIndex === currContentBox.length) {newIndex = newIndex - 1}
              if (oldIndex === currContentBox.length) {oldIndex = oldIndex - 1}
              const oldVal = utils.cloneDeep(currContentBox.slot[oldIndex])
              const newVal = utils.cloneDeep(currContentBox.slot[newIndex])
              utils.fill(currContentBox.slot, oldVal, newIndex, newIndex + 1)
              utils.fill(currContentBox.slot, newVal, oldIndex, oldIndex + 1)
            }
          } else {
            // 从一个盒子挪动到另一个盒子
            const currContentBox = utils.findConfig(templateStore.config, from.id)
            const currItemIndex = currContentBox.slot.findIndex((x: any) => x.id === item.id)
            const currItem = currContentBox.slot.splice(currItemIndex, 1)[0]
            const toContentBox = utils.findConfig(templateStore.config, to.id)
            toContentBox.slot.push(currItem)
          }
        }
      }
    })
  })
}

3、元素配置编辑区

该区域是用于编辑批改元素的行内款式,目前简略实现了字体、地位布局、背景、边框、暗影配置。

3.1 字体编辑

字体编辑性能应用富文本编辑器 tinymce,这里应用 vue3-tinymce,它是基于 vue@3.x + tinymce@5.8.x 封装的富文本编辑器。

更多配置可参考官网文档, 上面的对 vue3-tinymce 进行封装。

<template>
  <vue3-tinymce v-model="state.content" :setting="state.setting" />
</template>

<script lang="ts" setup>
import {reactive, watch} from 'vue';
// 引入组件
import Vue3Tinymce from '@jsdawn/vue3-tinymce'
import {useTemplateStore} from '@/stores/template'
import {findConfig} from '@/utils'

const template = useTemplateStore()
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

const state = reactive({
  content: '',
  setting: {
    height: 300,
    language: 'zh-Hans',
    language_url: '/tinymce/langs/zh-Hans.js'
  }
})

watch(() => props.modelValue, () => {props.modelValue && (state.content = findConfig(template.config, props.modelValue)?.value)
})

watch(() => state.content, () => {const config = findConfig(template.config, props.modelValue)
  config && (config.value = state.content)
})
</script>
3.2 地位布局

可批改元素的内外边距、宽高、布局类型(display)、定位类型(position)。

3.3 背景

可批改元素背景色彩、圆角、突变形式。

3.4 边框

可批改边框类型,包含无边框、实线、虚线、点线

3.5 暗影

可批改暗影色彩、以及暗影的 X、Y、间隔、大小。

根底组件

1、文字组件

<script lang="ts">
export default {name: "ContentInput"};
</script>

<script setup lang='ts'>
import {PropType} from 'vue';
import {useStyleFix} from '@/utils/hooks'

const props = defineProps({
  config: {type: Object as PropType<any>}
})
</script>

<template>
  <div 
    class="content-input"
    v-html="props.config.value"
    :style="[props.config.style, useStyleFix(props.config.style)]"
  >
  </div>
</template>

<style lang='scss' scoped>
.content-input {
  word-break: break-all;
  user-select: none;
}
</style>

2、图片组件

<script lang="ts">
export default {name: "ContentAsset"};
</script>

<script setup lang='ts'>
import {PropType} from 'vue'

const props = defineProps({
  config: {type: Object as PropType<any>}
})
</script>
<template>
  <div class="content-asset" :style="props.config.style">
    <img :src="props.config.value">
  </div>
</template>

<style lang='scss' scoped>
img {
  width: 100%;
  height: 100%;
}
</style>

3、盒子组件

<script lang="ts">
export default {name: "ContentBox"}
</script>

<script setup lang='ts'>
import {PropType} from 'vue'
const props = defineProps({
    config: {type: Object as PropType<any>}
})
</script>
<template>
  <div :class="['content-box', {'no-drag': props.config.noDrag}]" :style="props.config.style">
    <component v-for="item in props.config.slot" :key="item.id" :is="item.name" :config="item" :id="item.id"></component>
  </div>
</template>

<style lang='scss' scoped>
</style>

到这里根本的实现流程都结束,目前的版本还比较简单,还有很多能够实现的性能,比方撤回、重做、自定义组件选项、接入数据库等。

退出移动版