Implementation of low-code visual drag-and-drop editor

1. Foreword

With the continuous development of business, low-code and no-code platforms are becoming more and more common, which lower the development threshold, quickly respond to business needs, and improve development efficiency. Business personnel with zero development experience can quickly build various applications through visual dragging and other methods. This article mainly explains the implementation logic and scheme of the front-end display level of the low-code visual drag-and-drop platform, and does not cover the back-end logic, database design, and automated deployment for the time being.

2. Division of exhibition areas

First of all, we need to clarify the UI display effect we want to achieve, which is divided into three parts (component option area, visual display area, element configuration editing area) low code

1. Component option area

1.1 Data format definition

In order to display various elements, first define the type of element (text, picture, button, banner, form, etc.), the specific data format is as follows, for details, please check the source code path (src/config/template.ts, src/config/base .ts), each of these components can also be stored in the library, and retrieved through the interface query, but it is not implemented here.

  • template.ts: defines configuration for all types of custom components
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: The configuration that defines the base components in
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: []
   }
 }

Basic elements (text content-input, image content-asset) mainly include the following attributes: name (component name), style (inline style), value (content value)

The box element (content-box) mainly includes the following attributes: name (component name), style (inline style), noDrag (whether draggable), slot (slot content)

1.2 Implement draggability

In order to achieve the drag-and-drop effect, the sortable.js drag-and-drop library is used here. More usage details can be found in the official documentation

The key implementation code is as follows:

// 左侧选项区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) => {
        ...
      }
    })
  })
}

Here we mainly talk about the logic in onEnd. When dragging a component and moving it to the middle visual display area, the following two key operations need to be done.

  1. Determine whether to drag and drop into the visual display area
  2. Get the configuration of the current drag element, and update the value of store in pinia. (pinia is a new generation state management plugin for vue, which can be considered as 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. Visual display area

The function of the visual display area in the middle is mainly to provide users with the ability to select and drag specific elements. Therefore, it mainly realizes element display, check box and drag-and-drop functions.

2.1 Element display

Element display is relatively simple, you only need to configure config by traversing the pages in the pinia store, and display it with the dynamic component component tag

<component v-for="item in template.config" :key="item.id" :is="item.name" :config="item" :id="item.id">
</component>

2.2 Realize the check box

The logic of realizing the check box is relatively complicated, and the two key events are hover (the mouse hovers over the element) and select (the mouse clicks the element).

Define a reactive object to store their changes:

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

Define event listeners (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)
  })
})

Define and modify the catcher responsive object method

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()
  }
}

checkbox component

The check box component includes the check box body (distinguishes the box or element by different colors), and the function bar (move up and down, delete, copy).

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

The key point is to modify the global configuration when operating the function bar. For detailed logic, you can view the source code (src/components/mouse-catcher/index.vue)

2.3 Realize drag and drop in the visible area

The next step is to realize the draggability of the visual display area. This area is different from the option area, which allows the sorting of internal elements and dragging to other drag groups (boxes).

The key logic is as follows: (mainly analyze the logic in the onEnd callback)

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 Realize drag and drop in the box

Note here that you need to filter the class name content-box in the subpage of the visual area box, and do not include the class name no-drag.

The key logic is also in the onEnd callback function, which needs to distinguish three cases: the element moves inside the current box, the element moves to other boxes, and the element moves to the visual area (subpage) box.

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. Element configuration editing area

This area is used to edit and modify the inline style of elements. Currently, fonts, position layouts, backgrounds, borders, and shadow configurations are simply implemented.

3.1 Font Editing

The font editing function uses the rich text editor tinymce, here uses vue3-tinymce, which is a rich text editor based on [email protected] + [email protected] package.

For more configuration, please refer to the official document, the following encapsulates 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 Location layout

You can modify the inner and outer margins, width and height, layout type (display), and positioning type (position) of the element.

3.3 Background

You can modify the element background color, rounded corners, and gradient methods.

3.4 Border

The border type can be modified, including no border, solid line, dashed line, and dotted line

3.5 shadows

You can modify the shadow color, as well as the X, Y, distance, and size of the shadow.

Recommend a good low-code used some time ago , JNPF rapid development platform , adopts SpringBoot micro-service architecture, supports SpringCloud mode, improves the foundation of platform expansion, and meets rapid system development, flexible expansion, seamless integration and high-performance applications and other comprehensive capabilities; adopting the front-end and back-end separation mode, front-end and back-end developers can work together to be responsible for different sections, which saves trouble and is convenient. You can try it!

Basic components

text component

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

image component

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

box components

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

The basic implementation process has been completed here. The current version is relatively simple, and there are still many functions that can be realized, such as undo, redo, custom component options, access to the database, etc.

Guess you like

Origin blog.csdn.net/wangonik_l/article/details/131377146