前面我们学习低代码,例如百度的低代码平台 amis,也有相应的可视化编辑器,通过拖拽的方式生成配置文件。就像这样
笔者自己也有类似需求
:比如中台有个归档需求,通过选择一些配置让后端执行一些操作。目前只有A项目要归档,过些日子B项目也需要归档,后面还有 C项目归档。如果不想每次来都重新编码,最好做一个编辑器,配置好数据,归档功能根据编辑器生成的配置 json 自动生成岂不美哉!
本篇将开始自己实现一个可视化编辑器。
Tip:环境采用 spug 项目,一个开源的 react 后台系统。
需求
:新建一个页面,有组件区、编辑区、属性区
效果如下图所示:
核心代码如下:
// spug\src\pages\lowcodeeditor\index.js
import React from 'react';
import { Layout } from 'antd';
import { observer } from 'mobx-react';
import styles from './style.module.less'
const { Header, Footer, Sider, Content } = Layout;
export default observer(function () {
return (
<Layout className={styles.box}>
<Sider width='400' className={styles.componentBox}>组件区</Sider>
<Layout>
<Header className={styles.editorMenuBox}>菜单区</Header>
<Content className={styles.editorBox}>编辑区</Content>
</Layout>
<Sider width='400' className={styles.attributeBox}>属性区</Sider>
</Layout>
)
})
Tip: 直接用 antd 中的 layout 布局。
// spug\src\pages\lowcodeeditor\style.module.less
.box{
min-width: 1500px;
// color: pink;
font-size: 2em;
// 组件盒子
.componentBox{
background-color: blue;
}
// 编辑器菜单
.editorMenuBox{
background-color: pink;
}
// 编辑器盒子
.editorBox{
background-color: red;
}
// 属性盒子
.attributeBox{
background-color: yellow;
}
}
需求
:根据配置文件渲染编辑区
通常是从组件区拖拽组件到编辑区,编辑区就会显示该组件,这里先不管拖拽,直接通过定义数据,编辑区根据数据渲染。
这里采用绝对定位的玩法,你也可以选择其他的,比如拖拽到编辑器后释放,则渲染该组件,不支持拖动,就像 amis 编辑器。
思路:
Tip:最初打算将配置文件改为 .json 文件,后来发现缺少对应 loader 无法导入import data from './data.json'
。
效果如下图所示
核心代码如下:
将容器抽取成单独组件:
// spug\src\pages\lowcodeeditor\Container.js
import React from 'react';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import data from './data.js'
import store from './store';
import ComponentBlock from './ComponentBlock';
@observer
class Container extends React.Component {
componentDidMount() {
// 初始化
store.json = data
}
render() {
const {container = {}, components} = store.json || {};
const {width, height} = container
return (
<div className={styles.containerBox}>
<div className={styles.container} style={{width, height}}>
<ComponentBlock/>
</div>
</div>
)
}
}
export default Container
容器对应的样式:
// spug\src\pages\lowcodeeditor\style.module.less
.box{
...
// 容器盒子
.containerBox{
height: 100%;
border: 1px solid red;
padding: 5px;
// 容器的宽度高度有可能很大
overflow: auto;
}
// 容器
.container{
// 容器中的组件需要相对容器定位
position: relative;
margin:0 auto;
background: rgb(202, 199, 199);
border: 2px solid orange;
// 容器中渲染的组件
.componentBlock{
position: absolute;
}
}
}
配置数据格式如下:
// spug\src\pages\lowcodeeditor\data.js
const data = {
container: {
width: "800px",
height: "600px"
},
components: [
{top: 100, left: 300, 'zIndex': 1, type: 'text'},
{top: 200, left: 200, 'zIndex': 1, type: 'input'},
{top: 300, left: 100, 'zIndex': 1, type: 'button'},
]
}
export default data
组件块模块:
// spug\src\pages\lowcodeeditor\ComponentBlocks.js
import React, {Fragment} from 'react';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import store from './store';
@observer
class ComponentBlock extends React.Component {
componentDidMount() {
// 初始化
}
render() {
const { components} = store.json || {};
return (
<Fragment>
{
components?.map(item => <p className={styles.componentBlock} style={{left: item.left, top: item.top}}>我的类型是: {item.type}</p>)
}
</Fragment>
)
}
}
export default ComponentBlock
直接引入容器组件(Container):
// spug\src\pages\lowcodeeditor\index.js
...
import Container from './Container'
export default observer(function () {
return (
<Layout className={styles.box}>
<Layout>
<Header className={styles.editorMenuBox}>菜单区</Header>
<Content className={styles.editorBox}>
<Container/>
</Content>
</Layout>
</Layout>
)
})
将配置数据放入 store:
// spug\src\pages\lowcodeeditor\store.js
import { observable, computed } from 'mobx';
class Store {
// 配置数据
@observable json = null;
}
export default new Store()
上面我们根据一些假数据,实现能根据位置渲染内容
编辑区现在渲染的三个组件都是文本,我们期望的是正确的text
、input
和button
组件。
需求
:物料区渲染组件、编辑区渲染对应的三个组件
效果如下图所示:
核心代码如下:
编辑区提取出单独的模块:
// spug\src\pages\lowcodeeditor\index.js
import Material from './Material'
...
export default observer(function () {
return (
<Layout className={styles.box}>
<Sider width='400' className={styles.componentBox}>
<Material/>
</Sider>
</Layout>
)
})
物料区(即组件区)注册三个组件:
// 物料区(即组件区)
// spug\src\pages\lowcodeeditor\Material.js
import React, { Fragment } from 'react';
import { observer } from 'mobx-react';
import { Input, Button, Tag } from 'antd';
import styles from './style.module.less'
import store from './store';
@observer
class Material extends React.Component {
componentDidMount() {
// 初始化
this.registerMaterial()
}
// 注册物料
registerMaterial = () => {
store.registerMaterial({
// 组件类型
key: 'text',
// 组件文本
label: '文本',
// 组件预览。函数以便传达参数进来
preview: () => '预览文本',
// 组件渲染
render: () => '渲染文本',
})
store.registerMaterial({
key: 'button',
label: '按钮',
preview: () => <Button type="primary">预览按钮</Button>,
render: () => <Button type="primary">渲染按钮</Button>,
})
store.registerMaterial({
key: 'input',
label: '输入框',
preview: () => <Input style={{ width: '50%', }} placeholder='预览输入框' />,
render: () => <Input placeholder='渲染输入框' />,
})
}
render() {
return (
<Fragment>
{
store.componentList.map((item, index) =>
<section className={styles.combomentBlock} key={index}>
{/* 文案 */}
<Tag className={styles.combomentBlockLabel} color="cyan">{item.label}</Tag>
{/* 组件渲染 */}
<div className={styles.combomentBlockBox}>{item.preview()}</div>
</section>)
}
</Fragment>
)
}
}
export default Material
编辑区调整一下,根据 store.componentMap 渲染对应组件:
// spug\src\pages\lowcodeeditor\ComponentBlocks.js
@observer
class ComponentBlock extends React.Component {
render() {
const { components } = store.json || {};
return (
<Fragment>
{
components?.map(item =>
<div style={{ position: 'absolute', left: item.left, top: item.top }}>{store.componentMap[item.type].render()}</div>
)
}
</Fragment>
)
}
}
物料的数据都放入状态管理模块中:
// spug\src\pages\lowcodeeditor\store.js
import { observable, computed } from 'mobx';
class Store {
// 配置数据
@observable json = null;
@observable componentList = []
@observable componentMap = {}
// 注册物料
registerMaterial = (item) => {
this.componentList.push(item)
this.componentMap[item.key] = item
}
}
export default new Store()
对应样式:
// spug\src\pages\lowcodeeditor\style.module.less
.box{
min-width: 1500px;
// 组件盒子
.componentBox{
background-color: #fff;
// 组件块
.combomentBlock{
position: relative;
margin: 10px;
border: 1px solid #95de64;
.combomentBlockLabel{
position: absolute;
left:0;
top:0;
}
.combomentBlockBox{
display: flex;
align-items: center;
justify-content: center;
height: 100px;
}
}
// 组件块上添加一个蒙版,防止用户点击组件
.combomentBlock::after{
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0,0,0,.05);
// 增加移动效果
cursor: move;
}
}
...
需求
:将物料区的组件拖拽到编辑区,并在鼠标释放的地方渲染该组件
效果如下图所示:
物料区的组件需要增加 draggable
属性就可以拖动了。就像这样:
<section className={styles.combomentBlock} key={index}
// 元素可以拖拽
draggable
>
</section>)
效果如下:
核心代码如下:
修改物料组件,让物料组件支持拖拽,并在拖拽前记录拖动的组件。
// 物料区(即组件区)
// spug\src\pages\lowcodeeditor\Material.js
@observer
class Material extends React.Component {
// 记录拖动的元素
dragstartHander = (e, target) => {
// 标记正在拖拽
store.dragging = true;
// 记录拖拽的组件
store.currentDragedCompoent = target
// {"key":"text","label":"文本"}
// console.log(JSON.stringify(target))
}
render() {
return (
<Fragment>
{
store.componentList.map((item, index) =>
<section className={styles.combomentBlock} key={index}
// 元素可以拖拽
draggable
onDragStart={e => this.dragstartHander(e, item)}
>
{/* 文案 */}
<Tag className={styles.combomentBlockLabel} color="cyan">{item.label}</Tag>
{/* 组件预览 */}
<div className={styles.combomentBlockBox}>{item.preview()}</div>
</section>)
}
</Fragment>
)
}
}
export default Material
容器组件中初始化配置文件,默认容器大小是800*600,并给容器增加 dragover 和 drop 事件,并在 drop 事件里新增组件到数据中。
// spug\src\pages\lowcodeeditor\Container.js
import React from 'react';
import { observer } from 'mobx-react';
import styles from './style.module.less'
import store from './store';
import ComponentBlocks from './ComponentBlocks';
@observer
class Container extends React.Component {
componentDidMount() {
// 初始化
store.json = {
container: {
width: "800px",
height: "600px"
},
components: [
// {top: 100, left: 300, 'zIndex': 1, type: 'text'},
]
}
}
// 如果不阻止默认行为,则不会触发 drop,进入容器后也不会出现移动标识
dragOverHander = e => {
e.preventDefault()
}
// 新增组件到数据中
dropHander = e => {
store.dragging = false;
// e 中没有offsetX,到原始事件中找到 offsetX。
const { nativeEvent = {} } = e;
const component = {
top: nativeEvent.offsetY,
left: nativeEvent.offsetX,
zIndex: 1,
type: store.currentDragedCompoent.type
};
// 重置
store.currentDragedCompoent = null;
// 添加组件
store.json.components.push(component)
}
render() {
const { container = {}, components } = store.json || {};
const { width, height } = container
return (
<div className={styles.containerBox}>
<div className={styles.container} style={{ width, height, }}
onDragOver={this.dragOverHander}
onDrop={this.dropHander}
>
<ComponentBlocks />
</div>
</div>
)
}
}
export default Container
物料组件拖拽到编辑区,释放鼠标时需要在该位置渲染组件,而 offerX 会相对容器中的子元素定位,这里使用 pointer-events
解决:
// spug\src\pages\lowcodeeditor\ComponentBlocks.js
@observer
class ComponentBlocks extends React.Component {
render() {
const { components } = store.json || {};
return (
<Fragment>
{
components?.map((item, index) =>
// `pointer-events: none` 解决offsetX 穿透子元素的问题。
// pointer-events 兼容性高达98%以上
<div key={index} style={{ pointerEvents: store.dragging ? 'none' : 'auto', position: 'absolute', left: item.left, top: item.top }}>{store.componentMap[item.type]?.render()}</div>
)
}
</Fragment>
)
}
}
store 中新增数据方法如下:
// spug\src\pages\lowcodeeditor\store.js
class Store {
@observable dragging = false;
// 记录当前拖动的组件,drop 时置空
@observable currentDragedCompoent = null
// 注册物料
registerMaterial = (item) => {
this.componentList.push(item)
this.componentMap[item.type] = item
}
...
}
export default new Store()
相关知识点:
e.dataTransfer.dropEffect = "move"
也没效果pointer-events: none
解决offsetX 穿透子元素的问题。Tip:screenX、clientX、pageX, offsetX的区别如下图(来自网友 Demi
)所示
还有一个小问题:物料拖拽到编辑区释放时,新组件的左上角(left,top)正好是释放时鼠标的位置。
笔者期望鼠标释放的位置是新组件的正中心。
增加 translate(-50%, -50%)
即可。就像这样
// spug\src\pages\lowcodeeditor\ComponentBlocks.js
<div key={index} style={{
...
// 鼠标释放的位置是新组件的正中心
transform: `translate(-50%, -50%)`
}}>...</div>
不过这个方案在下面实现辅助线对齐时却遇到问题,需要替换方案。
这里分三步:首先是选中组件,然后拖拽选中的组件,最后添加对齐辅助线功能。
需求
:编辑区的组件支持选中。比如:
效果如下图所示:
这里用 mousedown 事件。核心代码如下:
// spug\src\pages\lowcodeeditor\ComponentBlocks.js
@observer
class ComponentBlocks extends React.Component {
mouseDownHandler = (e, target) => {
// 例如防止点击 input 时,input 会触发 focus 聚焦。
e.preventDefault()
// 如果按下 shift 则只处理单个
if (e.shiftKey) {
target.focus = !target.focus
} else if (!target.focus) {
// 清除所有选中
store.json.components.forEach(item => item.focus = false)
target.focus = true
} else {
// 这里无需清除所有选中
// 注销这句话拖动效果更好。
// target.focus = false
}
console.log(target)
}
render() {
const { components } = store.json || {};
return (
<Fragment>
{
components?.map((item, index) =>
<div key={index}
className={styles.containerBlockBox}
style={{
...
// 选中效果
border: item.focus ? '1.5px dashed red' : 'none',
}}
onMouseDown={e => this.mouseDownHandler(e, item)}
>...</div>
)
}
</Fragment>
)
}
}
export default ComponentBlocks
添加样式:
// spug\src\pages\lowcodeeditor\style.module.less
// 容器
.container{
...
// 编辑区的组件上添加一个蒙版,防止用户点击组件
.containerBlockBox::after{
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
}
需求
:编辑区内的选中的组件支持拖拽
思路:
核心代码如下:
// spug\src\pages\lowcodeeditor\Container.js
@observer
class Container extends React.Component {
...
// 点击容器,取消所有选中
clickHander = e => {
e.target.className.split(/\s+/).includes('container') &&
// 清除所有选中
store.json.components.forEach(item => item.focus = false)
}
mouseDownHandler = e => {
// 开始坐标
this.startCoordinate = {
x: e.pageX,
y: e.pageY
}
console.log('down。记录位置:', this.startCoordinate)
}
mouseMoveHander = e => {
if(!this.startCoordinate){
return
}
const {pageX, pageY} = e
const {x, y} = this.startCoordinate
// 移动的距离
const moveX = pageX - x
const moveY = pageY - y
console.log('move。更新位置。移动坐标:', {moveX, moveY})
store.focusComponents.forEach(item => {
item.top = item.top + moveY;
item.left = item.left + moveX;
})
// 更新开始位置。
this.startCoordinate = {
x: e.pageX,
y: e.pageY
}
}
mouseUpHander = e => {
console.log('up')
this.startCoordinate = null
}
render() {
const { container = {}, components } = store.json || {};
const { width, height } = container
return (
<div className={styles.containerBox}>
{/* 多个 className */}
<div className={`container ${styles.container}`} style={{ width, height, }}
...
onClick={this.clickHander}
onMouseDown={e => this.mouseDownHandler(e)}
onMouseMove={e => this.mouseMoveHander(e)}
onMouseUp={e => this.mouseUpHander(e)}
>
<ComponentBlocks />
</div>
</div>
)
}
}
export default Container
// spug\src\pages\lowcodeeditor\store.js
class Store {
// 配置数据
@observable json = null;
// 获取 json 中选中的项
@computed get focusComponents() {
return this.json.components.filter(item => item.focus)
}
// 获取 json 中未选中的项
@computed get unFocusComponents() {
return this.json.components.filter(item => !item.focus)
}
}
需求
:增加对齐辅助线。比如讲文本组件和按钮组件顶对齐、底对齐、居中对齐等等。靠近辅助线时也能自动贴上去。
效果如下图所示:
思路:
每个未选中的元素有10种情况。请看下图:
A 表示未选中元素,B表示选中拖动的元素,A 有三条辅助线,从上到下5种情况下显示:
这里是水平方向5中情况,垂直方向也有5种情况。
核心代码如下:
首先记录最后一个选中的元素。核心代码如下:
在 store.js 中增加如下相关变量:
startCoordinate - 作用:1. 记录开始坐标位置,用于计算移动偏移量 2. 松开鼠标后,辅助线消失
guide - 存放辅助线
adjacency - 拖拽的组件靠近辅助线时(2px内),辅助线出现
lastSelectedElement - 最后选中的元素
adjacencyGuides - 紧邻的辅助线,即要显示的辅助线
// spug\src\pages\lowcodeeditor\store.js
import { observable, computed } from 'mobx';
class Store {
// 开始坐标。作用:1. 记录开始坐标位置,用于计算移动偏移量 2. 松开鼠标后,辅助线消失
@observable startCoordinate = null
// 辅助线。xArray 存储垂直方向的辅助线;yArray 存储水平方向的辅助线;
@observable guide = { xArray: [], yArray: [] }
// 拖拽的组件靠近辅助线时(2px内),辅助线出现
@observable adjacency = 2
// 最后选中的元素索引。用于辅助线
@observable lastSelectedIndex = -1
// 最后选中的元素
@computed get lastSelectedElement() {
return this.json.components[this.lastSelectedIndex]
}
// 紧邻的辅助线,即要显示的辅助线
@computed get adjacencyGuides() {
return this.lastSelectedElement && this.guide.yArray
// 相对元素坐标与靠近辅助线时,辅助线出现
?.filter(item => Math.abs(item.y - this.lastSelectedElement.top) <= this.adjacency)
}
}
export default new Store()
ComponentBlocks.js 改动如下:
{showTop: xx, y: xx}
。笔者只实现了水平方向的辅助线。例如 A顶(未选中元素)对B底
的 y 的计算方式请看下图:将组件的选中样式从 border 改为不占空间的 outline
,防止取消选中时组件的抖动。
鼠标释放的位置是新组件的正中心方案修改。第一次从物料区拖拽组件释放时修改坐标。
// spug\src\pages\lowcodeeditor\ComponentBlocks.js
…
@observer
class ComponentBlocks extends React.Component {
mouseDownHandler = (e, target, index) => {
…
// 记录开始位置
store.startCoordinate = {
x: e.pageX,
y: e.pageY
}
...
// 初始化辅助线。选中就初始化辅助线,取消不管。
this.initGuide(target, index)
}
// 初始化辅助线。
// 注:仅完成水平辅助线,垂直辅助线请自行完成。
initGuide = (component, index) => {
// 记录最后一个选中元素的索引
// 问题:依次选中1个、2个、3个元素,然后取消第3个元素的选中,这时最后一个元素的索引依然指向第三个元素,这就不正确了。会导致辅助线中相对最后一个选中元素不正确。
// 解决办法:通过定义变量(store.startCoordinate)来解决此问题
store.lastSelectedIndex = component.focus ? index : -1
if (!component.focus) {
return
}
// console.log('初始化辅助线')
store.guide = { xArray: [], yArray: [] }
store.unFocusComponents.forEach(item => {
const { xArray: x, yArray: y } = store.guide
// 相对元素。即选中的最后一个元素
const { lastSelectedElement: relativeElement } = store
// A顶(未选中元素)对B底
// showTop 辅助线出现位置。y - 相对元素的 top 值为 y 时辅助线将显现
y.push({ showTop: item.top, y: item.top - relativeElement.height })
// A顶对B顶
y.push({ showTop: item.top, y: item.top })
// A中对B中
y.push({ showTop: item.top + item.height / 2, y: item.top + (item.height - relativeElement.height) / 2 })
// A底对B底
y.push({ showTop: item.top + item.height, y: item.top + item.height - relativeElement.height })
// A底对B顶
y.push({ showTop: item.top + item.height, y: item.top + item.height })
})
}
render() {
const { components } = store.json || {};
return (
)
}
}
// 必须加上 @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 (
Container.js 主要改动:
isFromMaterial - 鼠标释放的位置是新组件的正中心的一个标记
adjacencyGuideOffset - 自动贴近辅助线的偏移量。体验有些问题,默认关闭
mouseUpHander - mouseup 后辅助线不在显示
render() - 增加辅助线的渲染
// spug\src\pages\lowcodeeditor\Container.js
…
@observer
class Container extends React.Component {
…
dropHander = e => {
…
const component = {
// 从物料拖拽到编辑器。在编辑器初次显示后则关闭。
+ isFromMaterial: true,
top: nativeEvent.offsetY,
left: nativeEvent.offsetX,
zIndex: 1,
type: store.currentDragedCompoent.type
};
…
}
// 自动贴近辅助线的偏移量
// 体验有些问题,默认关闭。即贴近后,移动得慢会导致元素挪不开,因为自动贴近总会执行
// 注:只实现了Y轴(水平辅助线)
adjacencyGuideOffset = (close = true) => {
const result = { offsetY: 0, offsetX: 0 }
if (close) {
return result
}
// 取得接近的辅助线
const adjacencyGuide = store.guide.yArray
// 拖拽的组件靠近辅助线时(2px内),辅助线出现
.find(item => Math.abs(item.y - store.lastSelectedElement.top) <= store.adjacency)
if (adjacencyGuide) {
// 体验不好:取消贴近辅助线功能
result.offsetY = adjacencyGuide.y - store.lastSelectedElement.top;
}
return result
}
mouseMoveHander = e => {
// 选中元素后,再移动才有效
if (!store.startCoordinate) {
return
}
// 上个位置
const { x, y } = store.startCoordinate
let { pageX: newX, pageY: newY } = e
// 自动贴近偏移量。默认关闭此功能。
const { offsetY: autoApproachOffsetY, offsetX: autoApproachOffsetX } = this.adjacencyGuideOffset()
// 移动的距离
const moveX = newX - x + autoApproachOffsetX
const moveY = newY - y + autoApproachOffsetY
// console.log('move。更新位置。移动坐标:', {moveX, moveY})
store.focusComponents.forEach(item => {
item.left += moveX
item.top += moveY
})
// 更新开始位置。
store.startCoordinate = {
x: newX,
y: newY
}
}
// mouseup 后辅助线不在显示
mouseUpHander = e => {
store.startCoordinate = null
}
render() {
…
return (
</div>
</div>
)
}
}
export default Container
辅助线样式:
// spug\src\pages\lowcodeeditor\style.module.less
// 辅助线
.guide{
position: absolute;
width: 100%;
border-top: 1px dashed red;
}
手机扫一扫
移动阅读更方便
你可能感兴趣的文章