低代码 系列 —— 可视化编辑器3


可视化编辑器3

这是可视化编辑器的最后一篇,本篇主要实现属性区和组件的放大和缩小,最后附上所有代码

  • 属性区:即对编辑区的组件进行编辑,例如编辑文本的文本、字体大小,对按钮编辑文本、按钮类型、按钮大小
  • 放大和缩小:在编辑区能通过拖拽放大缩小组件。

首先看一下可视化编辑器最终效果

  • 从物料区拖拽组件到编辑区,批量删除组件、顶部对齐、居中对齐、底部对齐

  • 调整容器宽高、修改文本内容和大小、修改按钮文本/类型/大小

  • 批量组件操作,包括置底、置顶、撤销和删除,支持邮件菜单和快捷键

  • 组件放大和缩小

属性区

需求:对编辑区的组件(文本按钮)进行配置

  • 点击编辑区,对容器的宽度和高度进行配置,点击应用,能在编辑区看到效果
  • 点击编辑区的文本,可配置其文本、字体大小,点击应用,能在编辑区看到效果
  • 点击编辑区的按钮,可配置其文本、按钮类型、按钮大小,点击应用,能在编辑区看到效果
  • 支持撤回/重做。比如将文本大小从14px改成24px,点击撤回能回到 14px

Tip:组件的配置可以做的很丰富,根据自己业务来即可;编辑区可以只做适当的样式同步,例如尺寸、颜色,其他的配置效果则可放入预览中,例如 select 中的 option 选项、按钮的二次确认等等。就像 amis 中的这样:

效果展示

  • 对容器的宽度和高度进行配置

  • 对文本、按钮进行配置

  • 支持撤回/重做

基本思路

  • 根据最后选择的元素判断,如果是组件,则显示该组件的配置界面,否则显示容器的配置界面。就像这样:

  • 组件的配置项写在 store.registerMaterial 的 props 中。例如按钮有三个可配置项(文本、类型、大小):
// Material.js
store.registerMaterial({
    type: 'button',
    label: '按钮',
    preview: () => ,
    ...
    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: () => (
                
                    
                
        ),
        select: () => (
                
                     { props[propName] = e.target.value }} />
                
        ),
        select: () => (
                
                     { store.editorData.width = e.target.value }}/>
                
                
                     { store.editorData.height = e.target.value }}/>
                
            
        }

        // 组件的属性值
        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: () => (
                        
                             { props[propName] = e.target.value }} />
                        
                ),
                select: () => (
                        
                            ,
            // input 只允许调整宽度
            resize: {
                width: true,
            }
        })
    }
}
  • ComponentBlock.js - 选中后显现调整大小,并给渲染函数 render 传入宽度和高度,使其同步大小。
// spug\src\pages\lowcodeeditor\ComponentBlock.js

class ComponentBlocks extends React.Component {
  render() {
    return (
      
{/* 选中后显现调整大小 */} {item.focus && }
{store.componentMap[item.type]?.render(item.props, item)}
) } }
  • 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( this.mouseDownHandler(e, {x: 'left', y: 'center'})}>)

            result.push( this.mouseDownHandler(e, {x: 'right', y: 'center'})}>)

        }

        // 允许修改高度
        if (resize.height) {
            result.push( this.mouseDownHandler(e, {x: 'center', y: 'top'})}>)

            result.push( this.mouseDownHandler(e, {x: 'center', y: 'bottom'})}>)

        }

        // 允许修改宽度和高度
        if (resize.width && resize.height) {
            result.push( this.mouseDownHandler(e, {x: 'left', y: 'top'})}>)

            result.push( this.mouseDownHandler(e, {x: 'right', y: 'top'})}>)

            result.push( this.mouseDownHandler(e, {x: 'left', y: 'bottom'})}>)

            result.push( this.mouseDownHandler(e, {x: 'right', y: 'bottom'})}>)

        }

        return 
            {result}
        
    }
}

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 
                
                     { store.editorData.width = e.target.value }}/>
                
                
                     { store.editorData.height = e.target.value }}/>
                
            
        }

        // 组件的属性值
        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: () => (
                        
                             { props[propName] = e.target.value }} />
                        
                ),
                select: () => (
                        
                            ,
            render: (props, item) => ,
            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 (
            
                {
                    store.componentList.map((item, index) =>

                        
this.dragstartHander(e, item)} > {/* 文案 */} {item.label} {/* 组件预览 */}
{item.preview()}
) }
) } } 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 (
            
) } } 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;}