react 視覺化編輯器1

2022-10-19 21:00:18

視覺化編輯器1

前言

前面我們學習低程式碼,例如百度的低程式碼平臺 amis,也有相應的視覺化編輯器,通過拖拽的方式生成組態檔。就像這樣

筆者自己也有類似需求:比如中臺有個歸檔需求,通過選擇一些設定讓後端執行一些操作。目前只有A專案要歸檔,過些日子B專案也需要歸檔,後面還有 C專案歸檔。如果不想每次來都重新編碼,最好做一個編輯器,設定好資料,歸檔功能根據編輯器生成的設定 json 自動生成豈不美哉!

本篇將開始自己實現一個視覺化編輯器。

Tip:環境採用 spug 專案,一個開源的 react 後臺系統。

編輯器頁面框架搭建

需求:新建一個頁面,有元件區、編輯區、屬性區

效果如下圖所示:

核心程式碼如下:

// spug\src\pages\lowcodeeditor\index.js

import React from 'react';
import { Layout } from 'antd';
import { observer } from 'mobx-react';
import styles from './style.module.less'
const { Header, Footer, Sider, Content } = Layout;

export default observer(function () {
    return (
        <Layout className={styles.box}>
            <Sider width='400' className={styles.componentBox}>元件區</Sider>
            <Layout>
                <Header className={styles.editorMenuBox}>選單區</Header>
                <Content className={styles.editorBox}>編輯區</Content>
            </Layout>
            <Sider width='400' className={styles.attributeBox}>屬性區</Sider>
        </Layout>
    )
})

Tip: 直接用 antd 中的 layout 佈局。

// spug\src\pages\lowcodeeditor\style.module.less
.box{
    min-width: 1500px;
    // color: pink;
    font-size: 2em;

    // 元件盒子
    .componentBox{
        background-color: blue;
    }
    // 編輯器選單
    .editorMenuBox{
        background-color: pink;
    }
    // 編輯器盒子
    .editorBox{
        background-color: red;
    }
    // 屬性盒子
    .attributeBox{
        background-color: yellow;
    }
}

根據組態檔渲染編輯區

需求:根據組態檔渲染編輯區

通常是從元件區拖拽元件到編輯區,編輯區就會顯示該元件,這裡先不管拖拽,直接通過定義資料,編輯區根據資料渲染。

這裡採用絕對定位的玩法,你也可以選擇其他的,比如拖拽到編輯器後釋放,則渲染該元件,不支援拖動,就像 amis 編輯器。

思路:

  • 將編輯區提取出單獨模組 Container.js,完成對應css效果
  • 新建資料組態檔 data.js,存放頁面設定資料
  • Container 根據設定資料將元件渲染到編輯區

Tip:最初打算將組態檔改為 .json 檔案,後來發現缺少對應 loader 無法匯入import data from './data.json'

效果如下圖所示

核心程式碼如下:

將容器抽取成單獨元件:

// spug\src\pages\lowcodeeditor\Container.js
import React from 'react';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import data from './data.js'
import store from './store';
import ComponentBlock from './ComponentBlock';

@observer
class Container extends React.Component {
  componentDidMount() {
    // 初始化
    store.json = data
  }
  render() {
    const {container = {}, components} = store.json || {};
    const {width, height} = container
    return (
      <div className={styles.containerBox}>
          <div className={styles.container} style={{width, height}}>
            <ComponentBlock/>
          </div>
      </div>
    )
  }
}

export default Container

容器對應的樣式:

// spug\src\pages\lowcodeeditor\style.module.less
.box{
    ...
    // 容器盒子
    .containerBox{
        height: 100%;
        border: 1px solid red;
        padding: 5px;
        // 容器的寬度高度有可能很大
        overflow: auto;
    }
    // 容器
    .container{
        // 容器中的元件需要相對容器定位
        position: relative;
        margin:0 auto;
        background: rgb(202, 199, 199);
        border: 2px solid orange;
        // 容器中渲染的元件
        .componentBlock{
            position: absolute;
        }
    }
}

設定資料格式如下:

// spug\src\pages\lowcodeeditor\data.js

const data = {
   container: {
      width: "800px",
      height: "600px"
   },
   components: [
      {top: 100, left: 300, 'zIndex': 1, type: 'text'},
      {top: 200, left: 200, 'zIndex': 1, type: 'input'},
      {top: 300, left: 100, 'zIndex': 1, type: 'button'},
   ]
}

export default data

元件塊模組:

// spug\src\pages\lowcodeeditor\ComponentBlocks.js

import React, {Fragment} from 'react';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import store from './store';

@observer
class ComponentBlock extends React.Component {
  componentDidMount() {
    // 初始化
  }
  render() {
    const { components} = store.json || {};
    return (
      <Fragment>
            {
             components?.map(item => <p className={styles.componentBlock} style={{left: item.left, top: item.top}}>我的型別是: {item.type}</p>)
            }
      </Fragment>
    )
  }
}

export default ComponentBlock

直接引入容器元件(Container):

// spug\src\pages\lowcodeeditor\index.js
...
import Container from './Container'

export default observer(function () {
    return (
        <Layout className={styles.box}>
            <Layout>
                <Header className={styles.editorMenuBox}>選單區</Header>
                <Content className={styles.editorBox}>
                    <Container/>
                </Content>
            </Layout>
        </Layout>
    )
})

將設定資料放入 store:

// spug\src\pages\lowcodeeditor\store.js

import { observable, computed } from 'mobx';

class Store {
  // 設定資料
  @observable json = null;
}

export default new Store()

物料區元件渲染

上面我們根據一些假資料,實現能根據位置渲染內容
編輯區現在渲染的三個元件都是文字,我們期望的是正確的textinputbutton元件。

需求:物料區渲染元件、編輯區渲染對應的三個元件

效果如下圖所示:

核心程式碼如下:

編輯區提取出單獨的模組:

// spug\src\pages\lowcodeeditor\index.js

import Material from './Material'
...

export default observer(function () {
    return (
        <Layout className={styles.box}>
            <Sider width='400' className={styles.componentBox}>
                <Material/>
            </Sider>
        </Layout>
    )
})

物料區(即元件區)註冊三個元件:

// 物料區(即元件區)

// spug\src\pages\lowcodeeditor\Material.js

import React, { Fragment } from 'react';
import { observer } from 'mobx-react';
import { Input, Button, Tag } from 'antd';
import styles from './style.module.less'
import store from './store';

@observer
class Material extends React.Component {
    componentDidMount() {
        // 初始化
        this.registerMaterial()
    }

    // 註冊物料
    registerMaterial = () => {
        store.registerMaterial({
            // 元件型別
            key: 'text',
            // 元件文字
            label: '文字',
            // 元件預覽。函數以便傳達引數進來
            preview: () => '預覽文字',
            // 元件渲染
            render: () => '渲染文字',
        })

        store.registerMaterial({
            key: 'button',
            label: '按鈕',
            preview: () => <Button type="primary">預覽按鈕</Button>,
            render: () => <Button type="primary">渲染按鈕</Button>,
        })

        store.registerMaterial({
            key: 'input',
            label: '輸入框',
            preview: () => <Input style={{ width: '50%', }} placeholder='預覽輸入框' />,
            render: () => <Input placeholder='渲染輸入框' />,
        })
    }
    render() {
        return (
            <Fragment>
                {
                    store.componentList.map((item, index) =>
                        <section className={styles.combomentBlock} key={index}>
                            {/* 文案 */}
                            <Tag className={styles.combomentBlockLabel} color="cyan">{item.label}</Tag>
                            {/* 元件渲染 */}
                            <div className={styles.combomentBlockBox}>{item.preview()}</div>
                        </section>)
                }
            </Fragment>
        )
    }
}

export default Material

編輯區調整一下,根據 store.componentMap 渲染對應元件:

// spug\src\pages\lowcodeeditor\ComponentBlocks.js

@observer
class ComponentBlock extends React.Component {

  render() {
    const { components } = store.json || {};
    return (
      <Fragment>
        {
          components?.map(item =>
            <div style={{ position: 'absolute', left: item.left, top: item.top }}>{store.componentMap[item.type].render()}</div>
          )
        }
      </Fragment>
    )
  }
}

物料的資料都放入狀態管理模組中:

// spug\src\pages\lowcodeeditor\store.js

import { observable, computed } from 'mobx';

class Store {
  // 設定資料
  @observable json = null;

  @observable componentList = []

  @observable componentMap = {}

  // 註冊物料
  registerMaterial = (item) => {
    this.componentList.push(item)
    this.componentMap[item.key] = item
  }
}

export default new Store()

對應樣式:

// spug\src\pages\lowcodeeditor\style.module.less
.box{
    min-width: 1500px;
    // 元件盒子
    .componentBox{
        background-color: #fff;
        // 元件塊
        .combomentBlock{
            position: relative;
            margin: 10px;
            border: 1px solid #95de64;
            .combomentBlockLabel{
                position: absolute;
                left:0;
                top:0;
            }
            .combomentBlockBox{
                display: flex;
                align-items: center;
                justify-content: center;
                height: 100px;
            }
        }
        // 元件塊上新增一個蒙版,防止使用者點選元件
        .combomentBlock::after{
            content: '';
            position: absolute;
            left: 0;
            right: 0;
            top: 0;
            bottom: 0;
            background-color: rgba(0,0,0,.05);
            // 增加移動效果
            cursor: move;
        }
    }
    ...

物料元件拖拽到編輯區

需求:將物料區的元件拖拽到編輯區,並在滑鼠釋放的地方渲染該元件

效果如下圖所示:

物料區的元件需要增加 draggable 屬性就可以拖動了。就像這樣:

<section className={styles.combomentBlock} key={index} 
    // 元素可以拖拽
    draggable
>
       
</section>)

效果如下:

核心程式碼如下:

修改物料元件,讓物料元件支援拖拽,並在拖拽前記錄拖動的元件。

// 物料區(即元件區)

// spug\src\pages\lowcodeeditor\Material.js

@observer
class Material extends React.Component {
    
    // 記錄拖動的元素
    dragstartHander = (e, target) => {
        // 標記正在拖拽
        store.dragging = true;
        // 記錄拖拽的元件
        store.currentDragedCompoent = target

        // {"key":"text","label":"文字"}
        // console.log(JSON.stringify(target))
    }
    render() {
        return (
            <Fragment>
                {
                    store.componentList.map((item, index) =>

                        <section className={styles.combomentBlock} key={index}
                            // 元素可以拖拽
                            draggable
                            onDragStart={e => this.dragstartHander(e, item)}
                        >
                            {/* 文案 */}
                            <Tag className={styles.combomentBlockLabel} color="cyan">{item.label}</Tag>
                            {/* 元件預覽 */}
                            <div className={styles.combomentBlockBox}>{item.preview()}</div>
                        </section>)
                }
            </Fragment>
        )
    }
}

export default Material

容器元件中初始化組態檔,預設容器大小是800*600,並給容器增加 dragover 和 drop 事件,並在 drop 事件裡新增元件到資料中。

// spug\src\pages\lowcodeeditor\Container.js

import React from 'react';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import store from './store';
import ComponentBlocks from './ComponentBlocks';

@observer
class Container extends React.Component {
  componentDidMount() {
    // 初始化
    store.json = {
      container: {
        width: "800px",
        height: "600px"
      },
      components: [
        //  {top: 100, left: 300, 'zIndex': 1, type: 'text'},
      ]
    }
  }

  // 如果不阻止預設行為,則不會觸發 drop,進入容器後也不會出現移動標識
  dragOverHander = e => {
    e.preventDefault()
  }

  // 新增元件到資料中
  dropHander = e => {
    store.dragging = false;
    // e 中沒有offsetX,到原始事件中找到 offsetX。
    const { nativeEvent = {} } = e;
    const component = {
      top: nativeEvent.offsetY,
      left: nativeEvent.offsetX,
      zIndex: 1,
      type: store.currentDragedCompoent.type
    };
    // 重置
    store.currentDragedCompoent = null;
    // 新增元件
    store.json.components.push(component)

  }
  render() {
    const { container = {}, components } = store.json || {};
    const { width, height } = container
    return (
      <div className={styles.containerBox}>
        <div className={styles.container} style={{ width, height, }}
          onDragOver={this.dragOverHander}
          onDrop={this.dropHander}
        >
          <ComponentBlocks />
        </div>
      </div>
    )
  }
}

export default Container

物料元件拖拽到編輯區,釋放滑鼠時需要在該位置渲染元件,而 offerX 會相對容器中的子元素定位,這裡使用 pointer-events 解決:

// spug\src\pages\lowcodeeditor\ComponentBlocks.js

@observer
class ComponentBlocks extends React.Component {
  
  render() {
    const { components } = store.json || {};
    return (
      <Fragment>
        {
          components?.map((item, index) =>
            // `pointer-events: none` 解決offsetX 穿透子元素的問題。
            // pointer-events 相容性高達98%以上
            <div key={index} style={{ pointerEvents: store.dragging ? 'none' : 'auto', position: 'absolute', left: item.left, top: item.top }}>{store.componentMap[item.type]?.render()}</div>
          )
        }
      </Fragment>
    )
  }
}

store 中新增資料方法如下:

// spug\src\pages\lowcodeeditor\store.js

class Store {
  @observable dragging = false;

  // 記錄當前拖動的元件,drop 時置空
  @observable currentDragedCompoent = null

  // 註冊物料
  registerMaterial = (item) => {
    this.componentList.push(item)
    this.componentMap[item.type] = item
  }
  ...
}

export default new Store()

相關知識點:

  • dragenter - 當拖動的元素或被選擇的文字進入有效的放置目標時, dragenter 事件被觸發。
  • dragover - 當元素或者選擇的文字被拖拽到一個有效的放置目標上時,觸發 dragover 事件(每幾百毫秒觸發一次)
  • dragleave - 事件在拖動的元素或選中的文字離開一個有效的放置目標時被觸發。
  • drop - 事件在元素或選中的文字被放置在有效的放置目標上時被觸發。
  • DataTransfer.dropEffect 屬性控制在拖放操作中給使用者的反饋(通常是視覺上的)。它會影響在拖拽過程中游標的手勢。筆者設定了 e.dataTransfer.dropEffect = "move" 也沒效果
  • offsetX - 規定了事件物件與目標節點的內填充邊(padding edge)在 X 軸方向上的偏移量。會相對子元素,筆者採用 pointer-events: none 解決offsetX 穿透子元素的問題。

Tip:screenX、clientX、pageX, offsetX的區別如下圖(來自網友 Demi)所示

還有一個小問題:物料拖拽到編輯區釋放時,新元件的左上角(left,top)正好是釋放時滑鼠的位置。

筆者期望滑鼠釋放的位置是新元件的正中心。

增加 translate(-50%, -50%) 即可。就像這樣

// spug\src\pages\lowcodeeditor\ComponentBlocks.js

<div key={index} style={{
    ...
    // 滑鼠釋放的位置是新元件的正中心
    transform: `translate(-50%, -50%)`
}}>...</div>

不過這個方案在下面實現輔助線對齊時卻遇到問題,需要替換方案。

編輯區的元件拖拽

這裡分三步:首先是選中元件,然後拖拽選中的元件,最後新增對齊輔助線功能。

編輯區的元件選中

需求:編輯區的元件支援選中。比如:

  • 直接選中某元件就直接拖動(不釋放滑鼠)
  • 僅選中某元件
  • 選擇多個元件,例如按住 shift

效果如下圖所示:

這裡用 mousedown 事件。核心程式碼如下:

// spug\src\pages\lowcodeeditor\ComponentBlocks.js

@observer
class ComponentBlocks extends React.Component {
  mouseDownHandler = (e, target) => {
    // 例如防止點選 input 時,input 會觸發 focus 聚焦。
    e.preventDefault()

    // 如果按下 shift 則只處理單個
    if (e.shiftKey) {
      target.focus = !target.focus
    } else if (!target.focus) {
      // 清除所有選中
      store.json.components.forEach(item => item.focus = false)
      target.focus = true
    } else {
      // 這裡無需清除所有選中
      // 登出這句話拖動效果更好。
      // target.focus = false
    }
    console.log(target)
  }
  render() {
    const { components } = store.json || {};
    return (
      <Fragment>
        {
          components?.map((item, index) =>

            <div key={index}
              className={styles.containerBlockBox}
              style={{
                ...
                // 選中效果
                border: item.focus ? '1.5px dashed red' : 'none',
              }}
              onMouseDown={e => this.mouseDownHandler(e, item)}
            >...</div>
          )
        }
      </Fragment>
    )
  }
}

export default ComponentBlocks

新增樣式:

// spug\src\pages\lowcodeeditor\style.module.less
// 容器
.container{
    ...
    // 編輯區的元件上新增一個蒙版,防止使用者點選元件
    .containerBlockBox::after{
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        top: 0;
        bottom: 0;
    }
}

拖拽編輯區元件

需求:編輯區內的選中的元件支援拖拽

思路:

  • 點選容器,應取消所有選中元件
  • mousedown時記錄點選的開始位置座標,移動時計算偏移量並更新元件位置

核心程式碼如下:

// spug\src\pages\lowcodeeditor\Container.js

@observer
class Container extends React.Component {
  ...
  // 點選容器,取消所有選中
  clickHander = e => {
    e.target.className.split(/\s+/).includes('container') && 
    // 清除所有選中
    store.json.components.forEach(item => item.focus = false)
  }

  mouseDownHandler = e => {
    // 開始座標
    this.startCoordinate = {
      x: e.pageX,
      y: e.pageY
    }
    console.log('down。記錄位置:', this.startCoordinate)
  }

  mouseMoveHander = e => {
    if(!this.startCoordinate){
      return
    }
    const {pageX, pageY} = e
    const {x, y} = this.startCoordinate
    // 移動的距離
    const moveX = pageX - x
    const moveY = pageY - y
    console.log('move。更新位置。移動座標:', {moveX, moveY})
    store.focusComponents.forEach(item => {
      item.top = item.top + moveY;
      item.left = item.left + moveX;
    })

    // 更新開始位置。
    this.startCoordinate = {
      x: e.pageX,
      y: e.pageY
    }
  }

  mouseUpHander = e => {
    console.log('up')
    this.startCoordinate = null
  }

  render() {
    const { container = {}, components } = store.json || {};
    const { width, height } = container
    return (
      <div className={styles.containerBox}>
        {/* 多個 className */}
        <div className={`container ${styles.container}`} style={{ width, height, }}
          ...
          onClick={this.clickHander}
          onMouseDown={e => this.mouseDownHandler(e)}
          onMouseMove={e => this.mouseMoveHander(e)}
          onMouseUp={e => this.mouseUpHander(e)}
        >
          <ComponentBlocks />
        </div>
      </div>
    )
  }
}

export default Container
// spug\src\pages\lowcodeeditor\store.js

class Store {
  // 設定資料
  @observable json = null;
  // 獲取 json 中選中的項
  @computed get focusComponents() {
    return this.json.components.filter(item => item.focus)
  }

  // 獲取 json 中未選中的項
  @computed get unFocusComponents() {
    return this.json.components.filter(item => !item.focus)
  }

}

輔助線對齊

需求:增加對齊輔助線。比如講文字元件和按鈕元件頂對齊、底對齊、居中對齊等等。靠近輔助線時也能自動貼上去。

效果如下圖所示:

思路:

  • 記錄未選中元素的輔助線的位置以及顯現該輔助線的座標
  • 當前選中元素的座標如果和輔助線的座標匹配則顯示對應輔助線
  • 同時拖動多個元素,以最後選中的元素為準

每個未選中的元素有10種情況。請看下圖:

A 表示未選中元素,B表示選中拖動的元素,A 有三條輔助線,從上到下5種情況下顯示:

  • A頂對B底
  • A頂對B頂
  • A中對B中
  • A底對B底
  • A底對B頂

這裡是水平方向5中情況,垂直方向也有5種情況。

核心程式碼如下:

首先記錄最後一個選中的元素。核心程式碼如下:

在 store.js 中增加如下相關變數:

  • startCoordinate - 作用:1. 記錄開始座標位置,用於計算移動偏移量 2. 鬆開滑鼠後,輔助線消失
  • guide - 存放輔助線
  • adjacency - 拖拽的元件靠近輔助線時(2px內),輔助線出現
  • lastSelectedElement - 最後選中的元素
  • adjacencyGuides - 緊鄰的輔助線,即要顯示的輔助線
// spug\src\pages\lowcodeeditor\store.js

import { observable, computed } from 'mobx';

class Store {
  // 開始座標。作用:1. 記錄開始座標位置,用於計算移動偏移量 2. 鬆開滑鼠後,輔助線消失
  @observable startCoordinate = null

  // 輔助線。xArray 儲存垂直方向的輔助線;yArray 儲存水平方向的輔助線;
  @observable guide = { xArray: [], yArray: [] }

  // 拖拽的元件靠近輔助線時(2px內),輔助線出現
  @observable adjacency = 2

  // 最後選中的元素索引。用於輔助線
  @observable lastSelectedIndex = -1

  // 最後選中的元素
  @computed get lastSelectedElement() {
    return this.json.components[this.lastSelectedIndex]
  }

  // 緊鄰的輔助線,即要顯示的輔助線
  @computed get adjacencyGuides() {
    return this.lastSelectedElement && this.guide.yArray
      // 相對元素座標與靠近輔助線時,輔助線出現
      ?.filter(item => Math.abs(item.y - this.lastSelectedElement.top) <= this.adjacency)
  }
}

export default new Store()

ComponentBlocks.js 改動如下:

  • 將子元件的渲染抽離成一個單獨元件ComponentBlock。主要用於獲取元件的寬度和高度。元件得渲染後才知曉其尺寸
  • mousedown 時記錄滑鼠位置以及初始化輔助線
  • 輔助線有水平方向和垂直方向,分別存入 yArray 和 xArray 中。其資料結構為 {showTop: xx, y: xx}。筆者只實現了水平方向的輔助線。例如 A頂(未選中元素)對B底 的 y 的計算方式請看下圖:

  • 將元件的選中樣式從 border 改為不佔空間的 outline,防止取消選中時元件的抖動。
  • 滑鼠釋放的位置是新元件的正中心方案修改。第一次從物料區拖拽元件釋放時修改座標。
// spug\src\pages\lowcodeeditor\ComponentBlocks.js
...
@observer
class ComponentBlocks extends React.Component {
  mouseDownHandler = (e, target, index) => {
    ...

    // 記錄開始位置
    store.startCoordinate = {
      x: e.pageX,
      y: e.pageY
    }

    ...

    // 初始化輔助線。選中就初始化輔助線,取消不管。
    this.initGuide(target, index)
  }

  // 初始化輔助線。
  // 注:僅完成水平輔助線,垂直輔助線請自行完成。
  initGuide = (component, index) => {
    // 記錄最後一個選中元素的索引
    // 問題:依次選中1個、2個、3個元素,然後取消第3個元素的選中,這時最後一個元素的索引依然指向第三個元素,這就不正確了。會導致輔助線中相對最後一個選中元素不正確。
    // 解決辦法:通過定義變數(store.startCoordinate)來解決此問題
    store.lastSelectedIndex = component.focus ? index : -1

    if (!component.focus) {
      return
    }

    // console.log('初始化輔助線')
    store.guide = { xArray: [], yArray: [] }
    store.unFocusComponents.forEach(item => {

      const { xArray: x, yArray: y } = store.guide

      // 相對元素。即選中的最後一個元素
      const { lastSelectedElement: relativeElement } = store
      // A頂(未選中元素)對B底
      // showTop 輔助線出現位置。y - 相對元素的 top 值為 y 時輔助線將顯現
      y.push({ showTop: item.top, y: item.top - relativeElement.height })
      // A頂對B頂
      y.push({ showTop: item.top, y: item.top })
      // A中對B中
      y.push({ showTop: item.top + item.height / 2, y: item.top + (item.height - relativeElement.height) / 2 })
      // A底對B底
      y.push({ showTop: item.top + item.height, y: item.top + item.height - relativeElement.height })
      // A底對B頂
      y.push({ showTop: item.top + item.height, y: item.top + item.height })
    })
  }

  render() {
    const { components } = store.json || {};
    return (
      <Fragment>
        {
          components?.map((item, index) =>
            <ComponentBlock key={index} index={index} item={item} mouseDownHandler={this.mouseDownHandler} />
          )
        }
      </Fragment>
    )
  }
}

// 必須加上 @observer
// 將子元件拆分用於設定元件的寬度和高度
@observer
class ComponentBlock extends React.Component {
  constructor(props) {
    super(props)
    this.box = React.createRef()
  }
  componentDidMount() {

    // 初始化元件的寬度和高度
    // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetWidth
    const { offsetWidth, offsetHeight } = this.box.current
    const component = store.json.components[this.props.index] ?? {}
    component.width = offsetWidth
    component.height = offsetHeight

    // 元件第一次從物料區拖拽到編輯區,將元件中心位置設定為釋放滑鼠位置。
    // transform: `translate(-50%, -50%)` 的替代方案
    if (component.isFromMaterial) {
      component.isFromMaterial = false
      component.left = component.left - (component.width) / 2
      component.top = component.top - (component.height) / 2
    }
  }
  render() {
    const { index, item, mouseDownHandler } = this.props;
    return (
      <div ref={this.box}
        className={styles.containerBlockBox}
        style={{
          ...
          // 選中效果
          // border 改 outline(輪廓不佔據空間)。否則取消元素選中會因border消失而抖動
          outline: item.focus ? '1.5px dashed red' : 'none',
          // 滑鼠釋放的位置是新元件的正中心
          // transform: `translate(-50%, -50%)`,
        }}
        onMouseDown={e => mouseDownHandler(e, item, index)}
      >{store.componentMap[item.type]?.render()}</div>
    )
  }
}
export default ComponentBlocks

Container.js 主要改動:

  • isFromMaterial - 滑鼠釋放的位置是新元件的正中心的一個標記
  • adjacencyGuideOffset - 自動貼近輔助線的偏移量。體驗有些問題,預設關閉
  • mouseUpHander - mouseup 後輔助線不在顯示
  • render() - 增加輔助線的渲染
// spug\src\pages\lowcodeeditor\Container.js

...
@observer
class Container extends React.Component {
  ...

  dropHander = e => {
    ...
    const component = {
      // 從物料拖拽到編輯器。在編輯器初次顯示後則關閉。
    + isFromMaterial: true,
      top: nativeEvent.offsetY,
      left: nativeEvent.offsetX,
      zIndex: 1,
      type: store.currentDragedCompoent.type
    };
    ...
  }


  // 自動貼近輔助線的偏移量
  // 體驗有些問題,預設關閉。即貼近後,移動得慢會導致元素挪不開,因為自動貼近總會執行
  // 注:只實現了Y軸(水平輔助線)
  adjacencyGuideOffset = (close = true) => {
    const result = { offsetY: 0, offsetX: 0 }
    if (close) {
      return result
    }
    // 取得接近的輔助線
    const adjacencyGuide = store.guide.yArray
      // 拖拽的元件靠近輔助線時(2px內),輔助線出現
      .find(item => Math.abs(item.y - store.lastSelectedElement.top) <= store.adjacency)

    if (adjacencyGuide) {
      // 體驗不好:取消貼近輔助線功能
      result.offsetY = adjacencyGuide.y - store.lastSelectedElement.top;
    }
    return result
  }
  mouseMoveHander = e => {
    // 選中元素後,再移動才有效
    if (!store.startCoordinate) {
      return
    }
    // 上個位置
    const { x, y } = store.startCoordinate
    let { pageX: newX, pageY: newY } = e
    // 自動貼近偏移量。預設關閉此功能。
    const { offsetY: autoApproachOffsetY, offsetX: autoApproachOffsetX } = this.adjacencyGuideOffset()
    // 移動的距離
    const moveX = newX - x + autoApproachOffsetX
    const moveY = newY - y + autoApproachOffsetY

    // console.log('move。更新位置。移動座標:', {moveX, moveY})
    store.focusComponents.forEach(item => {
      item.left += moveX
      item.top += moveY
    })

    // 更新開始位置。
    store.startCoordinate = {
      x: newX,
      y: newY
    }
  }

  // mouseup 後輔助線不在顯示
  mouseUpHander = e => {
    store.startCoordinate = null
  }

  render() {
    ...
    return (
      <div className={styles.containerBox}>
        <div>
          <ComponentBlocks />
          {/* 輔助線 */}
          {
            store.startCoordinate && store.adjacencyGuides
              ?.map((item, index) => {
                return <i key={index} className={styles.guide} style={{ top: item.showTop }}></i>
              })
          }

        </div>
      </div>
    )
  }
}

export default Container

輔助線樣式:

// spug\src\pages\lowcodeeditor\style.module.less

// 輔助線
.guide{
    position: absolute;
    width: 100%;
    border-top: 1px dashed red;
}