前面我們學習低程式碼,例如百度的低程式碼平臺 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 中增加如下相關變數:
// 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 的計算方式請看下圖:outline
,防止取消選中時元件的抖動。// spug\src\pages\lowcodeeditor\ComponentBlocks.js
...
@observer
class ComponentBlocks extends React.Component {
mouseDownHandler = (e, target, index) => {
...
// 記錄開始位置
store.startCoordinate = {
x: e.pageX,
y: e.pageY
}
...
// 初始化輔助線。選中就初始化輔助線,取消不管。
this.initGuide(target, index)
}
// 初始化輔助線。
// 注:僅完成水平輔助線,垂直輔助線請自行完成。
initGuide = (component, index) => {
// 記錄最後一個選中元素的索引
// 問題:依次選中1個、2個、3個元素,然後取消第3個元素的選中,這時最後一個元素的索引依然指向第三個元素,這就不正確了。會導致輔助線中相對最後一個選中元素不正確。
// 解決辦法:通過定義變數(store.startCoordinate)來解決此問題
store.lastSelectedIndex = component.focus ? index : -1
if (!component.focus) {
return
}
// console.log('初始化輔助線')
store.guide = { xArray: [], yArray: [] }
store.unFocusComponents.forEach(item => {
const { xArray: x, yArray: y } = store.guide
// 相對元素。即選中的最後一個元素
const { lastSelectedElement: relativeElement } = store
// A頂(未選中元素)對B底
// showTop 輔助線出現位置。y - 相對元素的 top 值為 y 時輔助線將顯現
y.push({ showTop: item.top, y: item.top - relativeElement.height })
// A頂對B頂
y.push({ showTop: item.top, y: item.top })
// A中對B中
y.push({ showTop: item.top + item.height / 2, y: item.top + (item.height - relativeElement.height) / 2 })
// A底對B底
y.push({ showTop: item.top + item.height, y: item.top + item.height - relativeElement.height })
// A底對B頂
y.push({ showTop: item.top + item.height, y: item.top + item.height })
})
}
render() {
const { components } = store.json || {};
return (
<Fragment>
{
components?.map((item, index) =>
<ComponentBlock key={index} index={index} item={item} mouseDownHandler={this.mouseDownHandler} />
)
}
</Fragment>
)
}
}
// 必須加上 @observer
// 將子元件拆分用於設定元件的寬度和高度
@observer
class ComponentBlock extends React.Component {
constructor(props) {
super(props)
this.box = React.createRef()
}
componentDidMount() {
// 初始化元件的寬度和高度
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetWidth
const { offsetWidth, offsetHeight } = this.box.current
const component = store.json.components[this.props.index] ?? {}
component.width = offsetWidth
component.height = offsetHeight
// 元件第一次從物料區拖拽到編輯區,將元件中心位置設定為釋放滑鼠位置。
// transform: `translate(-50%, -50%)` 的替代方案
if (component.isFromMaterial) {
component.isFromMaterial = false
component.left = component.left - (component.width) / 2
component.top = component.top - (component.height) / 2
}
}
render() {
const { index, item, mouseDownHandler } = this.props;
return (
<div ref={this.box}
className={styles.containerBlockBox}
style={{
...
// 選中效果
// border 改 outline(輪廓不佔據空間)。否則取消元素選中會因border消失而抖動
outline: item.focus ? '1.5px dashed red' : 'none',
// 滑鼠釋放的位置是新元件的正中心
// transform: `translate(-50%, -50%)`,
}}
onMouseDown={e => mouseDownHandler(e, item, index)}
>{store.componentMap[item.type]?.render()}</div>
)
}
}
export default ComponentBlocks
Container.js 主要改動:
// spug\src\pages\lowcodeeditor\Container.js
...
@observer
class Container extends React.Component {
...
dropHander = e => {
...
const component = {
// 從物料拖拽到編輯器。在編輯器初次顯示後則關閉。
+ isFromMaterial: true,
top: nativeEvent.offsetY,
left: nativeEvent.offsetX,
zIndex: 1,
type: store.currentDragedCompoent.type
};
...
}
// 自動貼近輔助線的偏移量
// 體驗有些問題,預設關閉。即貼近後,移動得慢會導致元素挪不開,因為自動貼近總會執行
// 注:只實現了Y軸(水平輔助線)
adjacencyGuideOffset = (close = true) => {
const result = { offsetY: 0, offsetX: 0 }
if (close) {
return result
}
// 取得接近的輔助線
const adjacencyGuide = store.guide.yArray
// 拖拽的元件靠近輔助線時(2px內),輔助線出現
.find(item => Math.abs(item.y - store.lastSelectedElement.top) <= store.adjacency)
if (adjacencyGuide) {
// 體驗不好:取消貼近輔助線功能
result.offsetY = adjacencyGuide.y - store.lastSelectedElement.top;
}
return result
}
mouseMoveHander = e => {
// 選中元素後,再移動才有效
if (!store.startCoordinate) {
return
}
// 上個位置
const { x, y } = store.startCoordinate
let { pageX: newX, pageY: newY } = e
// 自動貼近偏移量。預設關閉此功能。
const { offsetY: autoApproachOffsetY, offsetX: autoApproachOffsetX } = this.adjacencyGuideOffset()
// 移動的距離
const moveX = newX - x + autoApproachOffsetX
const moveY = newY - y + autoApproachOffsetY
// console.log('move。更新位置。移動座標:', {moveX, moveY})
store.focusComponents.forEach(item => {
item.left += moveX
item.top += moveY
})
// 更新開始位置。
store.startCoordinate = {
x: newX,
y: newY
}
}
// mouseup 後輔助線不在顯示
mouseUpHander = e => {
store.startCoordinate = null
}
render() {
...
return (
<div className={styles.containerBox}>
<div>
<ComponentBlocks />
{/* 輔助線 */}
{
store.startCoordinate && store.adjacencyGuides
?.map((item, index) => {
return <i key={index} className={styles.guide} style={{ top: item.showTop }}></i>
})
}
</div>
</div>
)
}
}
export default Container
輔助線樣式:
// spug\src\pages\lowcodeeditor\style.module.less
// 輔助線
.guide{
position: absolute;
width: 100%;
border-top: 1px dashed red;
}