ローコードのビジュアルドラッグアンドドロップエディターの実装

1.はじめに

ビジネスの継続的な発展に伴い、ローコードおよびノー​​コード プラットフォームがますます一般的になり、開発の敷居が低くなり、ビジネス ニーズに迅速に対応し、開発効率が向上します。開発経験のないビジネスマンでも、視覚的なドラッグなどの方法でさまざまなアプリケーションを迅速に構築できます。この記事では主に、ローコード ビジュアル ドラッグ アンド ドロップ プラットフォームのフロントエンド表示レベルの実装ロジックとスキームについて説明し、当面はバックエンド ロジック、データベース設計、および自動展開については説明しません。

2.展示エリアの区分

まず実現したいUIの表示効果を明確にする必要があります。これはローコードの3つの部分(コンポーネントオプション領域、ビジュアル表示領域、要素構成編集領域)に分かれています

1. コンポーネントオプションエリア

1.1 データ形式の定義

さまざまな要素を表示するには、まず要素の種類 (テキスト、画像、ボタン、バナー、フォームなど) を定義します。具体的なデータ形式は次のとおりです。詳細については、ソース コードのパス (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: []
   }
 }

基本要素 (text content-input、image content-asset) には、主に次の属性が含まれます: name (コンポーネント名)、style (インライン スタイル)、value (コンテンツ値)

box 要素 (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 つのキー操作を行う必要があります。

  1. ビジュアル表示領域にドラッグ アンド ドロップするかどうかを決定します。
  2. 現在のドラッグ要素の構成を取得し、pinia のストアの値を更新します。(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 ストア内のページを移動して構成を設定し、動的コンポーネントのコンポーネント タグで表示するだけです。

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

2.2 チェックボックスを実現する

チェック ボックスを実現するロジックは比較的複雑で、2 つの重要なイベントは、ホバー (マウスが要素の上に移動する) と選択 (マウスが要素をクリックする) です。

変更を保存するためのリアクティブ オブジェクトを定義します。

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

イベント リスナーを定義します (マウスオーバー、クリック)

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

キャッチャー応答オブジェクト メソッドを定義および変更する

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 ボックス内でのドラッグ&ドロップを実現

ここで、ビジュアル領域ボックスのサブページでクラス名 content-box をフィルタリングする必要があり、クラス名 no-drag は含めないことに注意してください。

重要なロジックは onEnd コールバック関数にもあり、要素が現在のボックス内に移動する、要素が他のボックスに移動する、要素が視覚領域 (サブページ) ボックスに移動するという 3 つのケースを区別する必要があります。

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 を使用します。ここでは [email protected] + [email protected] パッケージをベースとしたリッチテキストエディタである vue3-tinymce を使用します。

詳しい設定については、公式ドキュメントを参照してください。以下は 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 場所のレイアウト

要素の内側と外側の余白、幅と高さ、レイアウト タイプ (表示)、配置タイプ (位置) を変更できます。

3.3 背景

要素の背景色、丸い角、グラデーション方法を変更できます。

3.4 境界線

枠線なし、実線、破線、点線などの枠線の種類を変更できます。

3.5 影

影の色、影の X、Y、距離、サイズを変更できます。

少し前に使用した優れたローコードを推奨するJNPF高速開発プラットフォームは、 SpringBootマイクロサービスアーキテクチャを採用し、SpringCloudモードをサポートし、プラットフォーム拡張の基盤を改善し、迅速なシステム開発、柔軟な拡張、シームレスな統合、および高性能アプリケーションを満たします。などの包括的な機能により、フロントエンドとバックエンドの分離モードを採用することで、フロントエンドとバックエンドの開発者が協力して異なるセクションを担当することができ、トラブルを軽減し、利便性を高めることができます。あなたはそれを試すことができます!

基本コンポーネント

テキストコンポーネント

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

画像コンポーネント

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

ボックスコンポーネント

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

基本的な実装プロセスはここで完了しました。現在のバージョンは比較的シンプルですが、元に戻す、やり直し、カスタム コンポーネント オプション、データベースへのアクセスなど、実現できる機能はまだ多くあります。

おすすめ

転載: blog.csdn.net/wangonik_l/article/details/131377146