低程式碼 系列 —— 視覺化編輯器3

彭加李發表於2022-12-07

視覺化編輯器3

這是視覺化編輯器的最後一篇,本篇主要實現屬性區和元件的放大和縮小,最後附上所有程式碼

  • 屬性區:即對編輯區的元件進行編輯,例如編輯文字的文字、字型大小,對按鈕編輯文字、按鈕型別、按鈕大小
  • 放大和縮小:在編輯區能透過拖拽放大縮小元件。

首先看一下視覺化編輯器最終效果

  • 從物料區拖拽元件到編輯區,批次刪除元件、頂部對齊、居中對齊、底部對齊

  • 調整容器寬高、修改文字內容和大小、修改按鈕文字/型別/大小

  • 批次元件操作,包括置底、置頂、撤銷和刪除,支援郵件選單和快捷鍵

  • 元件放大和縮小

屬性區

需求:對編輯區的元件(文字按鈕)進行配置

  • 點選編輯區,對容器的寬度和高度進行配置,點選應用,能在編輯區看到效果
  • 點選編輯區的文字,可配置其文字、字型大小,點選應用,能在編輯區看到效果
  • 點選編輯區的按鈕,可配置其文字、按鈕型別、按鈕大小,點選應用,能在編輯區看到效果
  • 支援撤回/重做。比如將文字大小從14px改成24px,點選撤回能回到 14px

Tip:元件的配置可以做的很豐富,根據自己業務來即可;編輯區可以只做適當的樣式同步,例如尺寸、顏色,其他的配置效果則可放入預覽中,例如 select 中的 option 選項、按鈕的二次確認等等。就像 amis 中的這樣:

效果展示

  • 對容器的寬度和高度進行配置

  • 對文字、按鈕進行配置

  • 支援撤回/重做

基本思路

  • 根據最後選擇的元素判斷,如果是元件,則顯示該元件的配置介面,否則顯示容器的配置介面。就像這樣:

  • 元件的配置項寫在 store.registerMaterial 的 props 中。例如按鈕有三個可配置項(文字、型別、大小):
// Material.js
store.registerMaterial({
    type: 'button',
    label: '按鈕',
    preview: () => <Button type="primary">預覽按鈕</Button>,
    ...
    props: {
        text: { type: 'input', label: '按鈕內容' },
        type: {
            type: 'select',
            label: '按鈕型別',
            options: [
                {label: '預設按鈕', value: 'default'},
                {label: '主按鈕', value: 'primary'},
                {label: '連結按鈕', value: 'link'},
            ],
        },
        size: {
            type: 'select',
            label: '按鈕大小',
            options: [
                {label: '小', value: 'small'},
                {label: '中', value: 'middle'},
                {label: '大', value: 'large'},
            ],
        },
    },
})
  • 元件的配置項根據其型別渲染,比如按鈕有一個 input,兩個 select。就像這樣:
// \lowcodeeditor\Attribute.js
return Object.entries(config.props).map(([propName, config], index) => 
    ({
        input: () => (
                <Form.Item key={index} label={config.label}>
                    <Input />
                </Form.Item>
        ),
        select: () => (
                <Form.Item key={index} label={config.label}>
                    <Select options={config.options} />
                </Form.Item>
        ),
    })[config.type]()
)
  • 配置屬性的雙向繫結及撤銷。這是一個 難點,可透過以下幾步來實現:
  1. 透過 mobx 的 autorun 監聽最後選擇元件索引,將該元件的資料深複製放入 store 中的新變數 editorData 中儲存
// 監聽屬性
autorun(() => {
    this.editorData = this.lastSelectedIndex > -1 ? _.cloneDeep(this.lastSelectedElement) : _.cloneDeep(this?.json?.container)
})
  1. 元件的配置變更時觸發 onChange 事件,將新值同步到 editorData
// \lowcodeeditor\Attribute.js

// 元件的屬性值
const props = store.editorData.props

// 例如文字的 config.props 為:{"text":{"type":"input","label":"文字內容"},"size":{"type":"select","label":"字型大小","options":[{"label":"14","value":"14px"},{"label":"18","value":"18px"},{"label":"24","value":"24px"}]}}
// propName - 配置的屬性名,根據其去 props 中取值
return Object.entries(config.props).map(([propName, config], index) => 
    ({
        input: () => (
                <Form.Item key={index} label={config.label}>
                    <Input value={props[propName]} onChange={e => { props[propName] = e.target.value }} />
                </Form.Item>
        ),
        select: () => (
                <Form.Item key={index} label={config.label}>
                    <Select options={config.options} value={props[propName]} onChange={v => { props[propName] = v }} />
                </Form.Item>
        ),
    })[config.type]()
)
  1. 點選 應用 觸發 apply 方法,先打快照(記錄整個應用之前的狀態),然後用 editorData 替換舊資料,完成備份
    Tip撤銷和重做更詳細的介紹請看 這裡
// Attribute.js

apply = (e) => {
    const lastIndex = store.lastSelectedIndex
    // 打快照
    store.snapshotStart()

    const editorData = _.cloneDeep(store.editorData)
    // 非容器
    if (lastIndex > -1) {
        store.json.components[lastIndex] = editorData
    } else {
        store.json.container = editorData
    }
    // 備份
    store.snapshotEnd()
}
  1. 編輯區的資料也需要同步,比如文字的字型大小改變了,在編輯區的該元件的樣式也需要同步。修改 render() 方法即可,渲染元件時傳遞資料進來。就像這樣:
store.registerMaterial({
    type: 'text',
    label: '文字',
    preview: () => '預覽文字',
    // 元件渲染。
    render: (props) => <span style={{fontSize: props.size}}>{props.text}</span>,
})
  1. 新拖拽的元件,需要設定預設值。增加 defaultProps。
    :defaultProps 中 key 和 props 的 key 必須一一對應
store.registerMaterial({
    // 元件型別
    type: 'text',
    ...
    props: {
        text: { type: 'input', label: '文字內容' },
        size: {
            type: 'select',
            label: '字型大小',
            options: [
                {label: '14', value: '14px'},
                {label: '18', value: '18px'},
                {label: '24', value: '24px'},
            ],
        }
    },
    // 預設值。
    // 約定:key 和 props 的 key 必須一一對應
    defaultProps: {
        text: '文字內容',
        size: '14px',
    }
})
const component = {
    isFromMaterial: true,
    top: nativeEvent.offsetY,
    left: nativeEvent.offsetX,
    zIndex: 1,
    type: store.currentDragedCompoent.type,
  + props: _.cloneDeep(store.currentDragedCompoent.defaultProps),
};

核心程式碼

  • index.js - 將屬性抽離成元件
// spug\src\pages\lowcodeeditor\index.js

<Sider width='400' className={styles.attributeBox}>
    <Attribute/>
</Sider>
  • Material.js - 給元件增加配置屬性(props)、預設屬性值(defaultProps),以及更改渲染(render)方法。
// 物料區(即元件區)
// spug\src\pages\lowcodeeditor\Material.js

class Material extends React.Component {
    // 註冊物料
    registerMaterial = () => {
        store.registerMaterial({
            // 元件型別
            type: 'text',
            // 元件文字
            label: '文字',
            // 元件預覽。函式以便傳達引數進來
            preview: () => '預覽文字',
            // 元件渲染。
            render: (props) => <span style={{fontSize: props.size}}>{props.text}</span>,
            // 元件可配置資料。例如文字元件有:文字內容、字型大小
            props: {
                // TODO: 多次複用,可提取出工廠函式
                text: { type: 'input', label: '文字內容' },
                size: {
                    type: 'select',
                    label: '字型大小',
                    options: [
                        {label: '14', value: '14px'},
                        {label: '18', value: '18px'},
                        {label: '24', value: '24px'},
                    ],
                }
            },
            // 預設值。
            // 約定:key 和 props 的 key 必須一一對應
            defaultProps: {
                text: '文字內容',
                size: '14px',
            }
        })

        store.registerMaterial({
            type: 'button',
            label: '按鈕',
            preview: () => <Button type="primary">預覽按鈕</Button>,
            render: (props) => <Button type={props.type} size={props.size}>{props.text}</Button>,
            props: {
                text: { type: 'input', label: '按鈕內容' },
                type: {
                    type: 'select',
                    label: '按鈕型別',
                    options: [
                        {label: '預設按鈕', value: 'default'},
                        {label: '主按鈕', value: 'primary'},
                        {label: '連結按鈕', value: 'link'},
                    ],
                },
                size: {
                    type: 'select',
                    label: '按鈕大小',
                    options: [
                        {label: '小', value: 'small'},
                        {label: '中', value: 'middle'},
                        {label: '大', value: 'large'},
                    ],
                },
            },
            // 約定:key 和 props 的 key 必須一一對應
            defaultProps: {
                text: 'Hello Button',
                type: 'primary', 
                size: 'middle',
            },
        })
    }
}
  • Attribute.js - 屬性模組
// spug\src\pages\lowcodeeditor\Attribute.js

class Attribute extends React.Component {
    apply = (e) => {

        const lastIndex = store.lastSelectedIndex
        // 打快照
        store.snapshotStart()

        const editorData = _.cloneDeep(store.editorData)
        // 非容器
        if (lastIndex > -1) {
            store.json.components[lastIndex] = editorData
        } else {
            store.json.container = editorData
        }
        // 備份
        store.snapshotEnd()
    }
    // 渲染元件
    renderComponent = () => {
        const lastElemType = store.lastSelectedElement?.type
        const config = store.componentMap[lastElemType]
        // 容器的配置
        if (!config) {
            return <Fragment>
                <Form.Item label="寬度">
                    <Input value={store.editorData?.width}  onChange={e => { store.editorData.width = e.target.value }}/>
                </Form.Item>
                <Form.Item label="高度">
                    <Input value={store.editorData?.height} onChange={e => { store.editorData.height = e.target.value }}/>
                </Form.Item>
            </Fragment>
        }

        // 元件的屬性值
        const props = store.editorData.props

        // 例如文字的 config.props 為:{"text":{"type":"input","label":"文字內容"},"size":{"type":"select","label":"字型大小","options":[{"label":"14","value":"14px"},{"label":"18","value":"18px"},{"label":"24","value":"24px"}]}}
        // propName - 配置的屬性名,根據其去 props 中取值
        return Object.entries(config.props).map(([propName, config], index) => 
            ({
                input: () => (
                        <Form.Item key={index} label={config.label}>
                            <Input value={props[propName]} onChange={e => { props[propName] = e.target.value }} />
                        </Form.Item>
                ),
                select: () => (
                        <Form.Item key={index} label={config.label}>
                            <Select options={config.options} value={props[propName]} onChange={v => { props[propName] = v }} />
                        </Form.Item>
                ),
            })[config.type]()
        )
    }
    render() {
        return (
            <div style={{ margin: 10 }}>
                <Form layout="vertical">
                    {
                        this.renderComponent()
                    }
                    <Form.Item>
                        <Button type="primary" onClick={this.apply}>
                            應用
                        </Button>
                    </Form.Item>
                </Form>
            </div>
        )
    }
}

放大縮小

需求:在編輯區能透過拖拽放大縮小元件。可以指定只調整 input 的寬度,button 可以調整寬度和高度。

效果展示

基本思路

  • 增加配置,例如 input 只允許調整寬度、button 允許調整寬度和高度
// lowcodeeditor\Material.js

store.registerMaterial({
    type: 'button',
    label: '按鈕',
    // 按鈕可以調整寬度和高度
    resize: {
        width: true,
        height: true,
    }
})

store.registerMaterial({
    type: 'input',
    label: '輸入框',
    // input 只允許調整寬度
    resize: {
        width: true,
    }
})
  • 根據配置,選中元素後繪製出相應的點,比如只能調成寬度的 input 顯示左右 2 個點,按鈕寬度和高度都可以改變,則顯示 8 個點。注意 z-index,前面我們做了編輯區元件的置頂和置底的功能。
// spug\src\pages\lowcodeeditor\CustomResize.js

class CustomResize extends React.Component {
    render() {
        const { resize = {} } = config

        const result = []
        // 允許修改寬度
        if (resize.width) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', marginLeft: '-4px', marginTop: '-4px', cursor: 'ew-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'left', y: 'center'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', right: 0, marginTop: '-4px', marginRight: '-4px', cursor: 'ew-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'right', y: 'center'})}></span>)

        }

        // 允許修改高度
        if (resize.height) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, left: '50%', marginLeft: '-4px', marginTop: '-4px',cursor: 'ns-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'center', y: 'top'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, bottom: 0, left: '50%', marginLeft: '-4px', marginBottom: '-4px',cursor: 'ns-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'center', y: 'bottom'})}></span>)

        }

        // 允許修改寬度和高度
        if (resize.width && resize.height) {
            ...
        }

        return <Fragment>
            {result}
        </Fragment>
    }
}
{/* 選中後顯現調整大小 */}
{item.focus && <CustomResize item={item}/> }
// 放大縮小
.resizePoint{
    position: absolute;
    width: 8px;
    height: 8px;
    background-color: green;
}
  • 拖動點改變元素寬度和高度。由於前面我們已經實現每個元素有 width 和 height 元素,現在需將這兩個屬性放入同步到渲染的時刻,因為拖動時需要根據拖動距離來改變元素尺寸。

筆者遇到一個困難:直接在每個點上註冊,移動快了,就失效。就像這樣:

result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', marginLeft: '-4px', marginTop: '-4px' }}
    onMouseDown={e => this.mouseDownHandler(e)}
    onMouseMove={e => this.mouseMoveHander(e)}
    onMouseUp={e => this.mouseUpHander(e)}
></span>)

移動快了,滑鼠的事件源就不在是那個點,導致 mouseup 事件也不會觸發。

後來直接在 document.body 上註冊就好了。效果如下:

Tip:當拖動頂部和左側的點是,需要調整元素的 left 和 top,否則效果不對。

程式碼如下:

result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', marginLeft: '-4px', marginTop: '-4px' }}
    onMouseDown={e => this.mouseDownHandler(e)}
></span>)

mouseDownHandler = (e) => {
    e.stopPropagation()

    const onmousemove = (e) => {
        ...
        item.width = width + moveX;
    }

    const onmouseup =  () => {
        document.body.removeEventListener('mousemove', onmousemove)
        document.body.removeEventListener('mouseup', onmouseup)
    }

    document.body.addEventListener('mousemove', onmousemove)
    document.body.addEventListener('mouseup', onmouseup)
}

核心程式碼

  • Material.js。增加配置,即指定元素是否可以調整寬度和高度;渲染時也得同步 width 和 height
// 物料區(即元件區)

// spug\src\pages\lowcodeeditor\Material.js
class Material extends React.Component {
    // 註冊物料
    registerMaterial = () => {
        store.registerMaterial({
            type: 'button',
            label: '按鈕',
            render: (props, item) => <Button type={props.type} size={props.size} style={{width: `${item?.width}px`, height: `${item?.height}px`}}>{props.text}</Button>,
            
            // 按鈕可以調整寬度和高度
            resize: {
                width: true,
                height: true,
            }
        })

        store.registerMaterial({
            type: 'input',
            label: '輸入框',
            render: (props, item) => <Input placeholder='渲染輸入框' style={{width: `${item?.width}px`}}/>,
            // input 只允許調整寬度
            resize: {
                width: true,
            }
        })
    }
}
  • ComponentBlock.js - 選中後顯現調整大小,並給渲染函式 render 傳入寬度和高度,使其同步大小。
// spug\src\pages\lowcodeeditor\ComponentBlock.js

class ComponentBlocks extends React.Component {
  render() {
    return (
      <div ref={this.box}>
        {/* 選中後顯現調整大小 */}
        {item.focus && <CustomResize item={item}/> }
        <Dropdown>
          <div>
            {store.componentMap[item.type]?.render(item.props, item)}
          </div>
        </Dropdown>
      </div>
    )
  }
}
  • CustomResize.js - 調整大小的元件。
// spug\src\pages\lowcodeeditor\CustomResize.js

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

function p(...opts) {
    console.log(...opts)
}
@observer
class CustomResize extends React.Component {
    state = { startCoordinate: null }
    mouseDownHandler = (e, {x: xAxios, y: yAxios}) => {
        // 阻止事件傳播,防止拖動元素
        e.stopPropagation()
        // 記錄開始座標
        this.setState({
            startCoordinate: {
                x: e.pageX,
                y: e.pageY,
                width: store.lastSelectedElement.width,
                height: store.lastSelectedElement.height,
                top: store.lastSelectedElement.top,
                left: store.lastSelectedElement.left,
            }
        })

        const onmousemove = (e) => {
            const { pageX, pageY } = e
            const { x, y, width, height, top, left } = this.state.startCoordinate

            // 移動的距離
            const moveX = pageX - x
            const moveY = pageY - y
            const item = store.lastSelectedElement

            // 根據頂部的點還是底部的點修改元素的寬度和高度
            if(yAxios === 'top'){
                item.height = height - moveY;
            }else if(yAxios === 'bottom'){
                item.height = height + moveY;
            }

            if(xAxios === 'left'){
                item.width = width - moveX;
            }else if(xAxios === 'right'){
                item.width = width + moveX;
            }

            // 頂部的點需要更改元素的 top
            if(yAxios === 'top'){
                item.top = top + moveY
            }
            // 左側的點需要更改元素的 left
            if(xAxios === 'left'){
                item.left = left + moveX
            }
            
        }

        const onmouseup = () => {
            p('up')
            document.body.removeEventListener('mousemove', onmousemove)
            document.body.removeEventListener('mouseup', onmouseup)
        }
        // 註冊事件
        document.body.addEventListener('mousemove', onmousemove)
        document.body.addEventListener('mouseup', onmouseup)
    }
   

    render() {
        const item = this.props.item;
        const config = store.componentMap[item.type]
        const { resize = {} } = config

        const result = []
        // 允許修改寬度
        if (resize.width) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', marginLeft: '-4px', marginTop: '-4px', cursor: 'ew-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'left', y: 'center'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', right: 0, marginTop: '-4px', marginRight: '-4px', cursor: 'ew-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'right', y: 'center'})}></span>)

        }

        // 允許修改高度
        if (resize.height) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, left: '50%', marginLeft: '-4px', marginTop: '-4px',cursor: 'ns-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'center', y: 'top'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, bottom: 0, left: '50%', marginLeft: '-4px', marginBottom: '-4px',cursor: 'ns-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'center', y: 'bottom'})}></span>)

        }

        // 允許修改寬度和高度
        if (resize.width && resize.height) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, marginLeft: '-4px', marginTop: '-4px', cursor: 'nwse-resize'  }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'left', y: 'top'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, right: 0, marginRight: '-4px', marginTop: '-4px',cursor: 'nesw-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'right', y: 'top'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, bottom: 0, marginLeft: '-4px', marginBottom: '-4px',cursor: 'nesw-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'left', y: 'bottom'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, right: 0, bottom: 0, marginRight: '-4px', marginBottom: '-4px', cursor: 'nwse-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'right', y: 'bottom'})}></span>)

        }

        return <Fragment>
            {result}
        </Fragment>
    }
}

export default CustomResize;

最終程式碼

一個最基本的編輯器就到此為止,還有許多可以完善的地方。

Tip:如果需要執行此專案,直接在開源專案 spug 上新增 lowcodeeditor 目錄即可。

全部程式碼目錄結構如下:

$ /e/spug/src/pages/lowcodeeditor (低程式碼編輯器)
$ ll
total 61
-rw-r--r-- 1 Administrator 197121 3182 Nov 30 09:25 Attribute.js
-rw-r--r-- 1 Administrator 197121 6117 Dec  5 15:30 ComponentBlocks.js
-rw-r--r-- 1 Administrator 197121 5132 Dec  6 10:34 Container.js
-rw-r--r-- 1 Administrator 197121 5269 Dec  7 10:18 CustomResize.js
-rw-r--r-- 1 Administrator 197121 5039 Dec  6 11:13 Material.js
-rw-r--r-- 1 Administrator 197121 9280 Nov 28 15:57 Menus.js
drwxr-xr-x 1 Administrator 197121    0 Oct 28 10:31 images/
-rw-r--r-- 1 Administrator 197121  118 Dec  6 14:46 index.css
-rw-r--r-- 1 Administrator 197121 1020 Nov  1 10:02 index.js
-rw-r--r-- 1 Administrator 197121 3927 Dec  5 16:41 store.js
-rw-r--r-- 1 Administrator 197121 2436 Dec  5 16:24 style.module.less

Attribute.js

屬性區。

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

import React, { Fragment } from 'react';
import { Button, Checkbox, Form, Input, Select } from 'antd';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import store from './store';
import _ from 'lodash'
// 除錯
function p(...opts) {
    console.log(...opts)
}
@observer
class Attribute extends React.Component {
    componentDidMount() {

    }
    apply = (e) => {

        const lastIndex = store.lastSelectedIndex
        // 打快照
        store.snapshotStart()

        const editorData = _.cloneDeep(store.editorData)
        // 非容器
        if (lastIndex > -1) {
            store.json.components[lastIndex] = editorData
        } else {
            store.json.container = editorData
        }
        // 備份
        store.snapshotEnd()
    }
    // 渲染元件
    renderComponent = () => {
        const lastElemType = store.lastSelectedElement?.type
        const config = store.componentMap[lastElemType]
        // 容器的配置
        if (!config) {
            return <Fragment>
                <Form.Item label="寬度">
                    <Input value={store.editorData?.width}  onChange={e => { store.editorData.width = e.target.value }}/>
                </Form.Item>
                <Form.Item label="高度">
                    <Input value={store.editorData?.height} onChange={e => { store.editorData.height = e.target.value }}/>
                </Form.Item>
            </Fragment>
        }

        // 元件的屬性值
        const props = store.editorData.props

        // 例如文字的 config.props 為:{"text":{"type":"input","label":"文字內容"},"size":{"type":"select","label":"字型大小","options":[{"label":"14","value":"14px"},{"label":"18","value":"18px"},{"label":"24","value":"24px"}]}}
        // propName - 配置的屬性名,根據其去 props 中取值
        return Object.entries(config.props).map(([propName, config], index) => 
            ({
                input: () => (
                        <Form.Item key={index} label={config.label}>
                            <Input value={props[propName]} onChange={e => { props[propName] = e.target.value }} />
                        </Form.Item>
                ),
                select: () => (
                        <Form.Item key={index} label={config.label}>
                            <Select options={config.options} value={props[propName]} onChange={v => { props[propName] = v }} />
                        </Form.Item>
                ),
            })[config.type]()
        )
    }
    render() {
        return (
            <div style={{ margin: 10 }}>
                <Form layout="vertical">
                    {
                        this.renderComponent()
                    }
                    <Form.Item>
                        <Button type="primary" onClick={this.apply}>
                            應用
                        </Button>
                    </Form.Item>
                </Form>
            </div>
        )
    }
}

export default Attribute

ComponentBlock.js

元件塊

// spug\src\pages\lowcodeeditor\ComponentBlock.js
import { Dropdown, Menu } from 'antd';
import React, { Fragment } from 'react';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import store from './store';
import Icon, { DeleteOutlined } from '@ant-design/icons';
import { ReactComponent as BottomSvg } from './images/set-bottom.svg'
import { ReactComponent as TopSvg } from './images/set-top.svg'
import CustomResize from './CustomResize'

// 右鍵選單
const ContextMenu = (
  <Menu>
    <Menu.Item onClick={() => store.snapshotState.commands.setTop()} >
      <Icon component={TopSvg} /> 置頂
    </Menu.Item>
    <Menu.Divider />
    <Menu.Item onClick={() => store.snapshotState.commands.setBottom()}>
      <Icon component={BottomSvg} /> 置底
    </Menu.Item>
    <Menu.Divider />
    <Menu.Item onClick={() => store.snapshotState.commands.delete()}>
      <DeleteOutlined /> 刪除
    </Menu.Item>
  </Menu>
);

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

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

    // 如果按下 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
    }

    // 初始化輔助線。選中就初始化輔助線,取消不管。
    this.initGuide(target, index)
    // console.log('target', JSON.stringify(target))

    // 快照
    store.snapshotStart()
  }

  // 初始化輔助線。
  // 注:僅完成水平輔助線,垂直輔助線請自行完成。
  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}
        style={{
          // `pointer-events: none` 解決offsetX 穿透子元素的問題。
          // pointer-events 相容性高達98%以上
          pointerEvents: store.dragging ? 'none' : 'auto',
          position: 'absolute',
          zIndex: item.zIndex,
          left: item.left,
          top: item.top,
          // 選中效果
          // border 改 outline(輪廓不佔據空間)。否則取消元素選中會因border消失而抖動
          outline: item.focus ? '1.5px dashed red' : 'none',
          // 滑鼠釋放的位置是新元件的正中心
          // transform: `translate(-50%, -50%)`,
        }}
        onMouseDown={e => mouseDownHandler(e, item, index)}
      >
        {/* 選中後顯現調整大小 */}
        {item.focus && <CustomResize item={item}/> }
        <Dropdown overlay={ContextMenu} trigger={['contextMenu']} style={{ background: '#000' }}>
          <div className={styles.containerBlockBox}>
            
            {store.componentMap[item.type]?.render(item.props, item)}
          </div>
        </Dropdown>
      </div>
    )
  }
}
export default ComponentBlocks

Container.js

容器。

// 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';
import _ from 'lodash'
@observer
class Container extends React.Component {

  componentDidMount() {
    // 初始化
    store.json = {
      container: {
        width: "800px",
        height: "600px"
      },
      components: [
        // test
        {
          top: 100,
          left: 100,
          zIndex: 1,
          type: 'text',
          // 注:與配置的順序得保持一致
          props: {
            text: '文字內容',
            size: '14px',
          }
        },
        {
          top: 200,
          left: 200,
          zIndex: 1,
          type: 'button',
          props: {
            text: 'Hello Button',
            type: 'primary',
            size: 'middle',
          },
        },
        {
          top: 300,
          left: 300,
          zIndex: 1,
          type: 'input',
        },
      ]
    }
  }

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

  dropHander = e => {
    store.dragging = false;
    // e 中沒有offsetX,到原始事件中找到 offsetX。
    const { nativeEvent = {} } = e;
    const component = {
      // 從物料拖拽到編輯器。在編輯器初次顯示後則關閉。
      isFromMaterial: true,
      top: nativeEvent.offsetY,
      left: nativeEvent.offsetX,
      zIndex: 1,
      type: store.currentDragedCompoent?.type,
      props: _.cloneDeep(store.currentDragedCompoent?.defaultProps),
    };
    // 重置
    store.currentDragedCompoent = null;
    // 新增元件
    store.json.components.push(component)

    // 打快照。將現在頁面的資料存入歷史,用於撤銷和重做。
    store.snapshotEnd()
  }

  // 點選容器,取消所有選中
  clickHander = e => {
    const classNames = e.target.className.split(/\s+/)
    if (classNames.includes('container')) {
      // 清除所有選中
      store.json.components.forEach(item => item.focus = false)
      // 重置
      store.lastSelectedIndex = -1
    }
  }

  // 自動貼近輔助線的偏移量
  // 體驗有些問題,預設關閉。即貼近後,移動得慢會導致元素挪不開,因為自動貼近總會執行
  // 注:只實現了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
    }
    // 標記:選中編輯區的元件後並移動
    store.isMoved = true
    // 上個位置
    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 => {
    if (store.isMoved) {
      store.isMoved = false
      store.snapshotEnd()
    }
    store.startCoordinate = null
  }

  render() {
    const { container = {} } = store.json || {};
    const { width, height } = container
    return (
      <div className={styles.containerBox}>
        {/* 多個 className */}
        <div className={`container ${styles.container}`} style={{ width, height, }}
          onDragOver={this.dragOverHander}
          onDrop={this.dropHander}
          onClick={this.clickHander}
          onMouseMove={e => this.mouseMoveHander(e)}
          onMouseUp={e => this.mouseUpHander(e)}
        >
          <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

CustomResize.js

調整尺寸。

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

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

function p(...opts) {
    console.log(...opts)
}
@observer
class CustomResize extends React.Component {
    state = { startCoordinate: null }
    mouseDownHandler = (e, {x: xAxios, y: yAxios}) => {
        // 阻止事件傳播,防止拖動元素
        e.stopPropagation()
        // 記錄開始座標
        this.setState({
            startCoordinate: {
                x: e.pageX,
                y: e.pageY,
                width: store.lastSelectedElement.width,
                height: store.lastSelectedElement.height,
                top: store.lastSelectedElement.top,
                left: store.lastSelectedElement.left,
            }
        })

        const onmousemove = (e) => {
            const { pageX, pageY } = e
            const { x, y, width, height, top, left } = this.state.startCoordinate

            // 移動的距離
            const moveX = pageX - x
            const moveY = pageY - y
            const item = store.lastSelectedElement

            // 根據頂部的點還是底部的點修改元素的寬度和高度
            if(yAxios === 'top'){
                item.height = height - moveY;
            }else if(yAxios === 'bottom'){
                item.height = height + moveY;
            }

            if(xAxios === 'left'){
                item.width = width - moveX;
            }else if(xAxios === 'right'){
                item.width = width + moveX;
            }

            // 頂部的點需要更改元素的 top
            if(yAxios === 'top'){
                item.top = top + moveY
            }
            // 左側的點需要更改元素的 left
            if(xAxios === 'left'){
                item.left = left + moveX
            }
            
        }

        const onmouseup = () => {
            p('up')
            document.body.removeEventListener('mousemove', onmousemove)
            document.body.removeEventListener('mouseup', onmouseup)
        }
        // 註冊事件
        document.body.addEventListener('mousemove', onmousemove)
        document.body.addEventListener('mouseup', onmouseup)
    }
   

    render() {
        const item = this.props.item;
        const config = store.componentMap[item.type]
        const { resize = {} } = config

        const result = []
        // 允許修改寬度
        if (resize.width) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', marginLeft: '-4px', marginTop: '-4px', cursor: 'ew-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'left', y: 'center'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, top: '50%', right: 0, marginTop: '-4px', marginRight: '-4px', cursor: 'ew-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'right', y: 'center'})}></span>)

        }

        // 允許修改高度
        if (resize.height) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, left: '50%', marginLeft: '-4px', marginTop: '-4px',cursor: 'ns-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'center', y: 'top'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, bottom: 0, left: '50%', marginLeft: '-4px', marginBottom: '-4px',cursor: 'ns-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'center', y: 'bottom'})}></span>)

        }

        // 允許修改寬度和高度
        if (resize.width && resize.height) {
            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, marginLeft: '-4px', marginTop: '-4px', cursor: 'nwse-resize'  }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'left', y: 'top'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, right: 0, marginRight: '-4px', marginTop: '-4px',cursor: 'nesw-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'right', y: 'top'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, bottom: 0, marginLeft: '-4px', marginBottom: '-4px',cursor: 'nesw-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'left', y: 'bottom'})}></span>)

            result.push(<span key={result.length} className={`${styles.resizePoint}`} style={{ zIndex: item.zIndex, right: 0, bottom: 0, marginRight: '-4px', marginBottom: '-4px', cursor: 'nwse-resize' }}
                onMouseDown={e => this.mouseDownHandler(e, {x: 'right', y: 'bottom'})}></span>)

        }

        return <Fragment>
            {result}
        </Fragment>
    }
}

export default CustomResize;

index.js

入口。

// 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'
import Container from './Container'
import Material from './Material'
import Menus from './Menus'
// 屬性區
import Attribute from './Attribute'
const { Header, Sider, Content } = Layout;

export default observer(function () {
    return (
        <Layout className={styles.box}>
            <Sider width='400' className={styles.componentBox}>
                <Material/>
            </Sider>
            <Layout>
                <Header className={styles.editorMenuBox}>
                    <Menus/>
                </Header>
                <Content className={styles.editorBox}>
                    <Container/>
                </Content>
            </Layout>
            <Sider width='400' className={styles.attributeBox}>
                <Attribute/>
            </Sider>
        </Layout>
    )
})

Material.js

// 物料區(即元件區)

// 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';
import _ from 'lodash'

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

    // 註冊物料
    registerMaterial = () => {
        store.registerMaterial({
            // 元件型別
            type: 'text',
            // 元件文字
            label: '文字',
            // 元件預覽。函式以便傳達引數進來
            preview: () => '預覽文字',
            // 元件渲染。
            render: (props) => <span style={{fontSize: props.size, userSelect: 'none'}}>{props.text}</span>,
            // 元件可配置資料。例如文字元件有:文字內容、字型大小
            props: {
                // TODO: 多次複用,可提取出工廠函式
                text: { type: 'input', label: '文字內容' },
                size: {
                    type: 'select',
                    label: '字型大小',
                    options: [
                        {label: '14', value: '14px'},
                        {label: '18', value: '18px'},
                        {label: '24', value: '24px'},
                    ],
                }
            },
            // 預設值。
            // 約定:key 和 props 的 key 必須一一對應
            defaultProps: {
                text: '文字內容',
                size: '14px',
            },
            
        })

        store.registerMaterial({
            type: 'button',
            label: '按鈕',
            preview: () => <Button type="primary">預覽按鈕</Button>,
            render: (props, item) => <Button type={props.type} size={props.size} style={{width: `${item?.width}px`, height: `${item?.height}px`}}>{props.text}</Button>,
            props: {
                text: { type: 'input', label: '按鈕內容' },
                type: {
                    type: 'select',
                    label: '按鈕型別',
                    options: [
                        {label: '預設按鈕', value: 'default'},
                        {label: '主按鈕', value: 'primary'},
                        {label: '連結按鈕', value: 'link'},
                    ],
                },
                size: {
                    type: 'select',
                    label: '按鈕大小',
                    options: [
                        {label: '小', value: 'small'},
                        {label: '中', value: 'middle'},
                        {label: '大', value: 'large'},
                    ],
                },
            },
            // 約定:key 和 props 的 key 必須一一對應
            defaultProps: {
                text: 'Hello Button',
                type: 'primary', 
                size: 'middle',
            },
            // 按鈕可以調整寬度和高度
            resize: {
                width: true,
                height: true,
            }
        })

        store.registerMaterial({
            type: 'input',
            label: '輸入框',
            preview: () => <Input style={{ width: '50%', }} placeholder='預覽輸入框' />,
            render: (props, item) => <Input placeholder='渲染輸入框' style={{width: `${item?.width}px`}}/>,
            props: {

            },
            // input 只允許調整寬度
            resize: {
                width: true,
            }
        })
    }
    // 記錄拖動的元素
    dragstartHander = (e, target) => {
        // 標記正在拖拽
        store.dragging = true;
        // 記錄拖拽的元件
        store.currentDragedCompoent = target

        // 打快照。用於記錄此刻頁面的資料,用於之後的撤銷
        store.snapshotStart()
        // {"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
// 選單區
// spug\src\pages\lowcodeeditor\Menus.js
import Icon, { RedoOutlined, UndoOutlined, DeleteOutlined } from '@ant-design/icons';
import React from 'react';
import { observer } from 'mobx-react';
import { Button, Space } from 'antd';
import styles from './style.module.less'
import store from './store';
import _ from 'lodash'
import { ReactComponent as BottomSvg } from './images/set-bottom.svg'
import { ReactComponent as TopSvg } from './images/set-top.svg'

@observer
class Menus extends React.Component {
    componentDidMount() {
        // 初始化
        this.registerCommand()

        // 所有按鍵均會觸發keydown事件
        window.addEventListener('keydown', this.onKeydown)
    }

    // 解除安裝事件
    componentWillUnmount() {
        window.removeEventListener('keydown', this.onKeydown)
    }

    // 取出快捷鍵對應的命令並執行命令
    onKeydown = (e) => {
        // console.log('down')
        // KeyboardEvent.ctrlKey 只讀屬性返回一個 Boolean 值,表示事件觸發時 control 鍵是 (true) 否 (false) 按下。
        // code 返回一個值,該值不會被鍵盤佈局或修飾鍵的狀態改變。當您想要根據輸入裝置上的物理位置處理鍵而不是與這些鍵相關聯的字元時,此屬性非常有用
        const { ctrlKey, code } = e
        const keyCodes = {
            KeyZ: 'z',
            KeyY: 'y',
        }
        // 未匹配則直接退出
        if (!keyCodes[code]) {
            return
        }
        // 生成快捷鍵,例如 ctrl+z
        let keyStr = []
        if (ctrlKey) {
            keyStr.push('ctrl')
        }
        keyStr.push(keyCodes[code])
        keyStr = keyStr.join('+')

        // 取出快捷鍵對應的命令
        let command = store.snapshotState.commandArray.find(item => item.keyboard === keyStr);
        // 執行該命令
        command = store.snapshotState.commands[command.name]
        command && command()
    }
    // 註冊命令。有命令的名字、命令的快捷鍵、命令的多個功能
    registerCommand = () => {
        // 重做命令。
        // store.registerCommand - 將命令存入 commandArray,並在建立命令名和對應的動作,比如 execute(執行), redo(重做), undo(撤銷)
        store.registerCommand({
            // 命令的名字
            name: 'redo',
            // 命令的快捷鍵
            keyboard: 'ctrl+y',
            // 命令執行入口。多層封裝用於傳遞引數給裡面的方法
            execute() {
                return {
                    // 從快照中取出下一個頁面狀態,呼叫對應的 redo 方法即可完成重做
                    execute() {
                        console.log('重做')
                        const { current, timeline } = store.snapshotState
                        let item = timeline[current + 1]
                        // 可以撤回
                        if (item?.redo) {
                            item.redo()
                            store.snapshotState.current++
                        }
                    }
                }
            }
        })

        // 撤銷
        store.registerCommand({
            name: 'undo',
            keyboard: 'ctrl+z',
            execute() {
                return {
                    // 從快照中取出當前頁面狀態,呼叫對應的 undo 方法即可完成撤銷
                    execute() {
                        console.log('撤銷')
                        const { current, timeline } = store.snapshotState
                        // 無路可退則返回
                        if (current == -1) {
                            return;
                        }

                        let item = timeline[current]
                        if (item) {
                            item.undo()
                            store.snapshotState.current--
                        }
                    }
                }
            }
        })

        store.registerCommand({
            name: 'drag',
            // 標記是否存入快照(timelime)中。例如拖拽動作改變了頁面狀態,需要往快照中插入
            pushTimeline: 'true',
            execute() {
                // 深複製頁面狀態資料
                let before = _.cloneDeep(store.snapshotState.before)
                let after = _.cloneDeep(store.json)
                // 重做和撤銷直接替換資料即可。
                return {
                    redo() {
                        console.log('重做2')
                        store.json = after
                    },
                    // 撤銷
                    undo() {
                        console.log('撤銷2')
                        store.json = before
                    }
                }
            }
        })

        // 置頂
        store.registerCommand({
            name: 'setTop',
            pushTimeline: 'true',
            execute() {
                // 深複製頁面狀態資料
                let before = _.cloneDeep(store.json)
                // 取得最大的zindex,然後將選中的元件的 zindex 設定為最大的 zindex + 1
                // 注:未處理 z-index 超出極限的場景
                let maxZIndex = Math.max(...store.json.components.map(item => item.zIndex))

                // 這種寫法也可以:
                // let maxZIndex = store.json.components.reduce((pre, elem) => Math.max(pre, elem.zIndex), -Infinity)
                
                store.focusComponents.forEach( item => item.zIndex = maxZIndex + 1)
                
                let after = _.cloneDeep(store.json)
                // 重做和撤銷直接替換資料即可。
                return {
                    redo() {
                        store.json = after
                    },
                    // 撤銷
                    undo() {
                        store.json = before
                    }
                }
            }
        })

        // 置底
        store.registerCommand({
            name: 'setBottom',
            pushTimeline: 'true',
            execute() {
                // 深複製頁面狀態資料
                let before = _.cloneDeep(store.json)
                let minZIndex = Math.min(...store.json.components.map(item => item.zIndex))

                // 如果最小值小於 1,最小值置為0,其他未選中的的元素都增加1
                // 注:不能簡單的拿到最最小值減1,因為若為負數(比如 -1),元件會到編輯器下面去,直接看不見了。
                if(minZIndex < 1){
                    store.focusComponents.forEach( item => item.zIndex = 0)
                    store.unFocusComponents.forEach( item => item.zIndex++ )
                }else {
                    store.focusComponents.forEach( item => item.zIndex = minZIndex - 1)
                }
                
                let after = _.cloneDeep(store.json)
                // 重做和撤銷直接替換資料即可。
                return {
                    redo() {
                        store.json = after
                    },
                    // 撤銷
                    undo() {
                        store.json = before
                    }
                }
            }
        })

        // 刪除
        store.registerCommand({
            name: 'delete',
            pushTimeline: 'true',
            execute() {
                // 深複製頁面狀態資料
                let before = _.cloneDeep(store.json)
                // 未選中的就是要保留的
                store.json.components = store.unFocusComponents
                
                let after = _.cloneDeep(store.json)
                // 重做和撤銷直接替換資料即可。
                return {
                    redo() {
                        store.json = after
                    },
                    // 撤銷
                    undo() {
                        store.json = before
                    }
                }
            }
        })
    }
    render() {
        return (
            <div style={{ textAlign: 'center' }}>
                <Space>
                    <Button type="primary" icon={<UndoOutlined />} onClick={() => store.snapshotState.commands.undo()}>撤銷</Button>
                    <Button type="primary" icon={<RedoOutlined />} onClick={() => store.snapshotState.commands.redo()}>重做</Button>
                    <Button type="primary" onClick={() => store.snapshotState.commands.setTop()}><Icon component={TopSvg} />置頂</Button>
                    <Button type="primary" onClick={() => store.snapshotState.commands.setBottom()}><Icon component={BottomSvg} />置底</Button>
                    <Button type="primary" icon={<DeleteOutlined />} onClick={() => store.snapshotState.commands.delete()}>刪除</Button>
                </Space>
            </div>
        )
    }
}

export default Menus

store.js

儲存狀態。

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

import { observable, computed,autorun } from 'mobx';
import _ from 'lodash'

class Store {
  constructor(){
    // 監聽屬性
    autorun(() => {
      this.editorData = this.lastSelectedIndex > -1 ? _.cloneDeep(this.lastSelectedElement) : _.cloneDeep(this?.json?.container)
    })
  }
  // 屬性編輯的元件資料
  @observable editorData = null

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

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

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

  @observable dragging = false;

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

  // 配置資料
  @observable json = null;

  @observable componentList = []

  @observable componentMap = {}

  // 快照。用於撤銷、重做
  @observable snapshotState = {
    // 編輯區選中元件拖動後則置為 true
    isMoved: false, 
    // 記錄之前的頁面狀態,用於撤銷
    before: null,
    current: -1, // 索引
    timeline: [], // 存放快照資料
    limit: 20, // 預設只能回退或撤銷最近20次。防止儲存的資料量過大
    commands: {}, // 命令和執行功能的對映 undo: () => {} redo: () => {}
    commandArray: [], // 存放所有命令
  }

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

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

  // 最後選中的元素
  @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)
  }

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

  // 註冊命令。將命令存入 commandArray,並在建立命令名和對應的動作,比如 execute(執行), redo(重做), undo(撤銷)
  registerCommand = (command) => {

    const { commandArray, commands } = this.snapshotState
    // 記錄命令
    commandArray.push(command)

    // 用函式包裹有利於傳遞引數
    commands[command.name] = () => {
      // 每個操作可以有多個動作。比如拖拽有撤銷和重做
      // 每個命令有個預設
      const { execute, redo, undo } = command.execute()
      execute && execute()

      // 無需存入歷史。例如撤銷或重做,只需要移動 current 指標。如果是拖拽,由於改變了頁面狀態,則需存入歷史
      if (!command.pushTimeline) {
        return
      }
      let {snapshotState: state} = this
      let { timeline, current, limit } = state
      // 新分支
      state.timeline = timeline.slice(0, current + 1)
      state.timeline.push({ redo, undo })
      // 只保留最近 limit 次操作記錄
      state.timeline = state.timeline.slice(-limit);
      state.current = state.timeline.length - 1;
    }
  }

  // 儲存快照。例如拖拽之前、移動以前觸發
  snapshotStart = () => {
    this.snapshotState.before = _.cloneDeep(this.json)
  }

  // 儲存快照。例如拖拽結束、移動之後觸發
  snapshotEnd = () => {
    this.snapshotState.commands.drag()
  }
}

export default new Store()

樣式檔案

// spug\src\pages\lowcodeeditor\style.module.less
.box{
    background-color: #fff;
    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;
        }
    }
   
    // 編輯器選單
    .editorMenuBox{
        background-color: #fff;
    }
    // 屬性盒子
    .attributeBox{
        background-color: #fff;
        margin-left: 10px;
        border-left: 2px solid #eee;
    }
    // 容器盒子
    .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;
        // 編輯區的元件上新增一個蒙版,防止使用者點選元件
        .containerBlockBox::after{
            content: '';
            position: absolute;
            left: 0;
            right: 0;
            top: 0;
            bottom: 0;
        }
        // 選中效果
        .containerFocusing{
            
        }
        // 輔助線
        .guide{
            position: absolute;
            width: 100%;
            border-top: 1px dashed red;
        }
        
        // 放大縮小
        .resizePoint{
            position: absolute;
            width: 8px;
            height: 8px;
            
            background-color: green;
        }
    }
}
// spug\src\pages\lowcodeeditor\index.css

/* 去除按鈕和 input 的動畫,否則調整按鈕大小時體驗差 */
.ant-btn,.ant-input{transition: none;}

相關文章