react 可视化编辑器1

可视化编辑器1

前言

前面我们学习低代码,例如百度的低代码平台 amis,也有相应的可视化编辑器,通过拖拽的方式生成配置文件。就像这样

react 可视化编辑器1插图

笔者自己也有类似需求:比如中台有个归档需求,通过选择一些配置让后端执行一些操作。目前只有A项目要归档,过些日子B项目也需要归档,后面还有 C项目归档。如果不想每次来都重新编码,最好做一个编辑器,配置好数据,归档功能根据编辑器生成的配置 json 自动生成岂不美哉!

本篇将开始自己实现一个可视化编辑器。

Tip:环境采用 spug 项目,一个开源的 react 后台系统。

编辑器页面框架搭建

需求:新建一个页面,有组件区、编辑区、属性区

效果如下图所示:
react 可视化编辑器1插图1

核心代码如下:

// spugsrcpageslowcodeeditorindex.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 (
        
            组件区
            
                
菜单区
编辑区
属性区
) })

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

// spugsrcpageslowcodeeditorstyle.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'

效果如下图所示

react 可视化编辑器1插图2

react 可视化编辑器1插图3

核心代码如下:

将容器抽取成单独组件:

// spugsrcpageslowcodeeditorContainer.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 (
      
) } } export default Container

容器对应的样式:

// spugsrcpageslowcodeeditorstyle.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;
        }
    }
}

配置数据格式如下:

// spugsrcpageslowcodeeditordata.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

组件块模块:

// spugsrcpageslowcodeeditorComponentBlocks.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 (
      
            {
             components?.map(item => 

我的类型是: {item.type}

) }
) } } export default ComponentBlock

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

// spugsrcpageslowcodeeditorindex.js
...
import Container from './Container'

export default observer(function () {
    return (
        
            
                
菜单区
) })

将配置数据放入 store:

// spugsrcpageslowcodeeditorstore.js

import { observable, computed } from 'mobx';

class Store {
  // 配置数据
  @observable json = null;
}

export default new Store()

物料区组件渲染

上面我们根据一些假数据,实现能根据位置渲染内容
编辑区现在渲染的三个组件都是文本,我们期望的是正确的textinputbutton组件。

需求:物料区渲染组件、编辑区渲染对应的三个组件

效果如下图所示:
react 可视化编辑器1插图4

核心代码如下:

编辑区提取出单独的模块:

// spugsrcpageslowcodeeditorindex.js

import Material from './Material'
...

export default observer(function () {
    return (
        
            
                
            
        
    )
})

物料区(即组件区)注册三个组件:

// 物料区(即组件区)

// spugsrcpageslowcodeeditorMaterial.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: () => ,
            render: () => ,
        })

        store.registerMaterial({
            key: 'input',
            label: '输入框',
            preview: () => ,
            render: () => ,
        })
    }
    render() {
        return (
            
                {
                    store.componentList.map((item, index) =>
                        
{/* 文案 */} {item.label} {/* 组件渲染 */}
{item.preview()}
) }
) } } export default Material

编辑区调整一下,根据 store.componentMap 渲染对应组件:

// spugsrcpageslowcodeeditorComponentBlocks.js

@observer
class ComponentBlock extends React.Component {

  render() {
    const { components } = store.json || {};
    return (
      
        {
          components?.map(item =>
            
{store.componentMap[item.type].render()}
) }
) } }

物料的数据都放入状态管理模块中:

// spugsrcpageslowcodeeditorstore.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()

对应样式:

// spugsrcpageslowcodeeditorstyle.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;
        }
    }
    ...

物料组件拖拽到编辑区

需求:将物料区的组件拖拽到编辑区,并在鼠标释放的地方渲染该组件

效果如下图所示:
react 可视化编辑器1插图5

物料区的组件需要增加 draggable 属性就可以拖动了。就像这样:

)

效果如下:
react 可视化编辑器1插图6

核心代码如下:

修改物料组件,让物料组件支持拖拽,并在拖拽前记录拖动的组件。

// 物料区(即组件区)

// spugsrcpageslowcodeeditorMaterial.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 (
            
                {
                    store.componentList.map((item, index) =>

                        
this.dragstartHander(e, item)} > {/* 文案 */} {item.label} {/* 组件预览 */}
{item.preview()}
) }
) } } export default Material

容器组件中初始化配置文件,默认容器大小是800*600,并给容器增加 dragover 和 drop 事件,并在 drop 事件里新增组件到数据中。

// spugsrcpageslowcodeeditorContainer.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 (
      
) } } export default Container

物料组件拖拽到编辑区,释放鼠标时需要在该位置渲染组件,而 offerX 会相对容器中的子元素定位,这里使用 pointer-events 解决:

// spugsrcpageslowcodeeditorComponentBlocks.js

@observer
class ComponentBlocks extends React.Component {

  render() {
    const { components } = store.json || {};
    return (
      
        {
          components?.map((item, index) =>
            // pointer-events: none 解决offsetX 穿透子元素的问题。
            // pointer-events 兼容性高达98%以上
            
{store.componentMap[item.type]?.render()}
) }
) } }

store 中新增数据方法如下:

// spugsrcpageslowcodeeditorstore.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)所示

react 可视化编辑器1插图7

还有一个小问题:物料拖拽到编辑区释放时,新组件的左上角(left,top)正好是释放时鼠标的位置。

笔者期望鼠标释放的位置是新组件的正中心。

增加 translate(-50%, -50%) 即可。就像这样

// spugsrcpageslowcodeeditorComponentBlocks.js

...

不过这个方案在下面实现辅助线对齐时却遇到问题,需要替换方案。

编辑区的组件拖拽

这里分三步:首先是选中组件,然后拖拽选中的组件,最后添加对齐辅助线功能。

编辑区的组件选中

需求:编辑区的组件支持选中。比如:

  • 直接选中某组件就直接拖动(不释放鼠标)
  • 仅选中某组件
  • 选择多个组件,例如按住 shift

效果如下图所示:

react 可视化编辑器1插图8

这里用 mousedown 事件。核心代码如下:

// spugsrcpageslowcodeeditorComponentBlocks.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 (
      
        {
          components?.map((item, index) =>

            
this.mouseDownHandler(e, item)} >...
) }
) } } export default ComponentBlocks

添加样式:

// spugsrcpageslowcodeeditorstyle.module.less
// 容器
.container{
    ...
    // 编辑区的组件上添加一个蒙版,防止用户点击组件
    .containerBlockBox::after{
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        top: 0;
        bottom: 0;
    }
}

拖拽编辑区组件

需求:编辑区内的选中的组件支持拖拽

思路:

  • 点击容器,应取消所有选中组件
  • mousedown时记录点击的开始位置坐标,移动时计算偏移量并更新组件位置

核心代码如下:

// spugsrcpageslowcodeeditorContainer.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 (
      
{/* 多个 className */}
this.mouseDownHandler(e)} onMouseMove={e => this.mouseMoveHander(e)} onMouseUp={e => this.mouseUpHander(e)} >
) } } export default Container
// spugsrcpageslowcodeeditorstore.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)
  }

}

辅助线对齐

需求:增加对齐辅助线。比如讲文本组件和按钮组件顶对齐、底对齐、居中对齐等等。靠近辅助线时也能自动贴上去。

效果如下图所示:

react 可视化编辑器1插图9
react 可视化编辑器1插图10

思路:

  • 记录未选中元素的辅助线的位置以及显现该辅助线的坐标
  • 当前选中元素的坐标如果和辅助线的坐标匹配则显示对应辅助线
  • 同时拖动多个元素,以最后选中的元素为准

每个未选中的元素有10种情况。请看下图:

react 可视化编辑器1插图11

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 - 紧邻的辅助线,即要显示的辅助线
// spugsrcpageslowcodeeditorstore.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) 

ComponentBlocks.js 改动如下:

  • 将子组件的渲染抽离成一个单独组件ComponentBlock。主要用于获取组件的宽度和高度。组件得渲染后才知晓其尺寸
  • mousedown 时记录鼠标位置以及初始化辅助线
  • 辅助线有水平方向和垂直方向,分别存入 yArray 和 xArray 中。其数据结构为 {showTop: xx, y: xx}。笔者只实现了水平方向的辅助线。例如 A顶(未选中元素)对B底 的 y 的计算方式请看下图:

react 可视化编辑器1插图12

  • 将组件的选中样式从 border 改为不占空间的 outline,防止取消选中时组件的抖动。
  • 鼠标释放的位置是新组件的正中心方案修改。第一次从物料区拖拽组件释放时修改坐标。
// spugsrcpageslowcodeeditorComponentBlocks.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 (
      
        {
          components?.map((item, index) =>
            
          )
        }
      
    )
  }
}

// 必须加上 @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 (
      
mouseDownHandler(e, item, index)} >{store.componentMap[item.type]?.render()}
) } } export default ComponentBlocks

Container.js 主要改动:

  • isFromMaterial - 鼠标释放的位置是新组件的正中心的一个标记
  • adjacencyGuideOffset - 自动贴近辅助线的偏移量。体验有些问题,默认关闭
  • mouseUpHander - mouseup 后辅助线不在显示
  • render() - 增加辅助线的渲染
// spugsrcpageslowcodeeditorContainer.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)  {
    // 选中元素后,再移动才有效
    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 (
      
{/* 辅助线 */} { store.startCoordinate && store.adjacencyGuides ?.map((item, index) => { return }) }
) } } export default Container

辅助线样式:

// spugsrcpageslowcodeeditorstyle.module.less

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

文章来源于互联网:react 可视化编辑器1

THE END
分享
二维码