Implementación del editor visual de arrastrar y soltar de código bajo

1. Prólogo

Con el desarrollo continuo de los negocios, las plataformas low-code y sin código son cada vez más comunes, lo que reduce el umbral de desarrollo, responde rápidamente a las necesidades comerciales y mejora la eficiencia del desarrollo. El personal comercial sin experiencia en desarrollo puede crear rápidamente varias aplicaciones mediante el arrastre visual y otros métodos. Este artículo explica principalmente la lógica de implementación y el esquema del nivel de visualización frontal de la plataforma visual de arrastrar y soltar de código bajo, y no cubre la lógica de back-end, el diseño de la base de datos y la implementación automatizada por el momento.

2. División de áreas de exhibición

En primer lugar, debemos aclarar el efecto de visualización de la interfaz de usuario que queremos lograr, que se divide en tres partes (área de opción de componente, área de visualización visual, área de edición de configuración de elementos) código bajo

1. Área de opciones de componentes

1.1 Definición del formato de datos

Para mostrar varios elementos, primero defina el tipo de elemento (texto, imagen, botón, banner, formulario, etc.), el formato de datos específico es el siguiente, para obtener más detalles, verifique la ruta del código fuente (src/config/ template.ts, src/config/base .ts), cada uno de estos componentes también se puede almacenar en la biblioteca y recuperar a través de la consulta de la interfaz, pero no se implementa aquí.

  • template.ts: define la configuración para todos los tipos de componentes personalizados
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: la configuración que define los componentes base en
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: []
   }
 }

Los elementos básicos (entrada de contenido de texto, activo de contenido de imagen) incluyen principalmente los siguientes atributos: nombre (nombre del componente), estilo (estilo en línea), valor (valor del contenido)

El elemento de cuadro (cuadro de contenido) incluye principalmente los siguientes atributos: nombre (nombre del componente), estilo (estilo en línea), noDrag (si se puede arrastrar), ranura (contenido de la ranura)

1.2 Implementar capacidad de arrastre

Para lograr el efecto de arrastrar y soltar, aquí se utiliza la biblioteca de arrastrar y soltar sortable.js. Se pueden encontrar más detalles de uso en la documentación oficial.

El código de implementación clave es el siguiente:

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

Aquí hablamos principalmente de la lógica en onEnd. Al arrastrar un componente y moverlo al área de visualización central, se deben realizar las siguientes dos operaciones clave.

  1. Determinar si arrastrar y soltar en el área de visualización
  2. Obtenga la configuración del elemento de arrastre actual y actualice el valor de store en pinia. (pinia es un complemento de administración de estado de nueva generación para vue, que puede considerarse como 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. Área de visualización visual

La función del área de visualización en el medio es principalmente brindar a los usuarios la capacidad de seleccionar y arrastrar elementos específicos. Por lo tanto, realiza principalmente funciones de visualización de elementos, casillas de verificación y arrastrar y soltar.

2.1 Visualización de elementos

La visualización de elementos es relativamente simple, solo necesita configurar la configuración recorriendo las páginas en la tienda pinia y mostrarla con la etiqueta de componente de componente dinámico

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

2.2 Realizar la casilla de verificación

La lógica de realizar la casilla de verificación es relativamente complicada, y los dos eventos clave son pasar el mouse (el mouse pasa sobre el elemento) y seleccionar (el mouse hace clic en el elemento).

Defina un objeto reactivo para almacenar sus cambios:

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

Definir detectores de eventos (pasar el mouse, hacer clic)

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

Defina y modifique el método de objeto receptivo del receptor

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

componente de casilla de verificación

El componente de casilla de verificación incluye el cuerpo de la casilla de verificación (distingue la casilla o el elemento por diferentes colores) y la barra de funciones (mover hacia arriba y hacia abajo, eliminar, copiar).

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

El punto clave es modificar la configuración global al operar la barra de funciones. Para una lógica detallada, puede ver el código fuente (src/components/mouse-catcher/index.vue)

2.3 Darse cuenta de arrastrar y soltar en el área visible

El siguiente paso es darse cuenta de la capacidad de arrastre del área de visualización visual.Esta área es diferente del área de opciones, que permite clasificar los elementos internos y arrastrarlos a otros grupos de arrastre (cajas).

La lógica clave es la siguiente: (principalmente analice la lógica en la devolución de llamada 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 Darse cuenta de arrastrar y soltar en el cuadro

Tenga en cuenta aquí que debe filtrar el cuadro de contenido del nombre de la clase en la subpágina del cuadro del área visual y no incluir el nombre de la clase sin arrastrar.

La lógica clave también se encuentra en la función de devolución de llamada onEnd, que debe distinguir tres casos: el elemento se mueve dentro del cuadro actual, el elemento se mueve a otros cuadros y el elemento se mueve al cuadro del área visual (subpágina).

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. Área de edición de configuración de elementos

Esta área se utiliza para editar y modificar el estilo en línea de los elementos.Actualmente, las fuentes, los diseños de posición, los fondos, los bordes y las configuraciones de sombras se implementan de forma sencilla.

3.1 Edición de fuentes

La función de edición de fuentes usa el editor de texto enriquecido tinymce, aquí usa vue3-tinymce, que es un editor de texto enriquecido basado en el paquete [email protected] + [email protected].

Para obtener más configuraciones, consulte el documento oficial, lo siguiente encapsula 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 Diseño de la ubicación

Puede modificar los márgenes interior y exterior, el ancho y el alto, el tipo de diseño (visualización) y el tipo de posicionamiento (posición) del elemento.

3.3 Antecedentes

Puede modificar el color de fondo del elemento, las esquinas redondeadas y los métodos de degradado.

3.4 Borde

El tipo de borde se puede modificar, incluyendo sin borde, línea continua, línea discontinua y línea punteada

3.5 sombras

Puede modificar el color de la sombra, así como el X, Y, la distancia y el tamaño de la sombra.

Recomendamos un buen código bajo utilizado hace algún tiempo , la plataforma de desarrollo rápido JNPF , adopta la arquitectura de microservicio SpringBoot, admite el modo SpringCloud, mejora la base de la expansión de la plataforma y cumple con el desarrollo rápido del sistema, la expansión flexible, la integración perfecta y las aplicaciones de alto rendimiento y otras capacidades integrales; adoptando el modo de separación de front-end y back-end, los desarrolladores de front-end y back-end pueden trabajar juntos para ser responsables de diferentes secciones, ahorrando problemas y conveniencia. ¡Puedes probarlo!

Componentes básicos

componente de texto

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

componente de imagen

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

componentes de la caja

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

El proceso básico de implementación se ha completado aquí. La versión actual es relativamente simple y todavía hay muchas funciones que se pueden realizar, como deshacer, rehacer, opciones de componentes personalizados, acceso a la base de datos, etc.

Supongo que te gusta

Origin blog.csdn.net/wangonik_l/article/details/131377146
Recomendado
Clasificación