前言
最近在公司有涉及到低代码相关工作,对 LowCode 可视化拖拽技术进行了调研,特此使用一个系列文章记录这一过程。
起初参考珠峰架构 - 姜文老师 低代码可视化搭建平台 公开课直播分享,来入门可视化拖拽技术,在此提及感谢。
系列文章将使用 React 技术栈,来搭建一个可视化低代码平台,以及一些拖拽编辑器主要功能点的实现。
低代码 - 可视化拖拽技术分析(1)- 拖拽编辑器基础功能实现
低代码 - 可视化拖拽技术分析(2)- 拖拽辅助线、吸附和放大缩小
低代码 - 可视化拖拽技术分析(3)- 撤销与重做
低代码 - 可视化拖拽技术分析(4)- Settings 设置器实现
本篇,我们从项目搭建开始,去完成以下功能:
- 编辑器布局;
- 自定义物料组件;
- 拖拽物料组件;
- 编辑器画布元素获取焦点;
- 编辑器画布元素拖动/移动。
本节实现效果图如下:
一、关键字概念
系列文章内会时常提起到以下几个概念,先在这里简单提及:
material代表左侧物料区可拖动的物料组件;block代表中间画布区域内的元素(本篇重点);setter代表右侧属性设置区内的属性设置器。
二、项目搭建
我们采用 create-react-app 快速生成一个 React 开发环境:
npx create-react-app low-code-example
简单优化一下目录结构,其中 Editor 将作为我们的应用的主要文件:
三、编辑器布局
我们对页面结构改造如下:
function Editor() {
return (
<div className="editor-wrapper">
<div className="editor-header">顶部工具栏</div>
<div className="editor-main">
<div className="editor-left">左侧物料区</div>
<div className="editor-container">
<div id="canvas-container">编辑区 - 画布</div>
</div>
<div className="editor-right">右侧属性区</div>
</div>
</div>
);
}
其中 id="canvas-container" 元素将作为拖拽画布区域。下面我们为画布设置宽高尺寸。
import React from 'react';
import SchemaJSON from './schema.json';
function Editor() {
const [schema, setSchema] = useState(SchemaJSON);
// ... 省略其他 DOM 节点
<div id="canvas-container" style={{ ...schema.container }}>编辑区 - 画布</div>
}
schema,一种 JSON 数据结构,用于描述编辑器画布信息,以及画布中拖拽添加的组件元素信息。这里我们使用到了画布的宽高信息,在 schema JSON 中结构如下:
// src/schema.json
{
"container": {
"width": 375, // iPhone 6/7/8 尺寸(具体按照设计稿尺寸)
"height": 667
},
"blocks": [
// 画布中拖拽的元素集合,下面介绍
]
}
至此,我们的基本架子就搭建出来了;下面我们定义一些物料组件,用于后续拖拽至画布使用。
四、自定义物料组件
在编辑器左侧区存放了我们定义的一个个物料组件,也就是我们日常开发使用到的 UI 组件。我们使用config 文件来管理物料组件的注册。
const createEditorConfig = () => {
const componentList = []; // 自定义组件(物料)
const componentMap = {}; // 组件和画布中元素的渲染映射
return {
componentList,
componentMap,
register: (component) => {
componentList.push(component);
componentMap[component.type] = component;
}
}
}
const registerConfig = createEditorConfig();
registerConfig.register({
label: '文本',
preview: () => '预览文本',
render: () => '渲染文本',
type: 'text'
});
registerConfig.register({
label: '按钮',
preview: () => <button>预览按钮</button>,
render: () => <button>渲染按钮</button>,
type: 'button'
});
registerConfig.register({
label: '输入框',
preview: () => <input placeholder="预览输入框" />,
render: () => <input placeholder="渲染输入框" />,
type: 'input'
});
export default registerConfig;
这里,我们注册了三个物料组件:文本、按钮、输入框,并且对外暴露了两个关键变量:
componentList: 存储了一个个物料组件的集合,用于渲染编辑器左侧物料的数据源;componentMap: 是一个 map 映射关系,根据type字段进行映射,有了它,就可以在画布中通过Schema数据去匹配物料组件来渲染画布视图。
有了数据,我们就可以通过 map 渲染左侧区物料组件:
import config from './config';
function Editor() {
// ... 省略其他 DOM 节点
<div className="editor-left">
{config.componentList.map(component => (
<div key={component.type} className="editor-left-item">
<span>{component.label}</span>
<div>{component.preview()}</div>
</div>
))}
</div>
}
接下来,我们让物料组件能够自由拖动。
五、拖拽物料组件至画布
这里我们采用 HTML5 拖拽特性,为 DOM 节点添加 draggable 属性后即可自由拖动:
<div draggable key={component.type} className="editor-left-item">...</div>
我们还需要通过拖拽事件:当拖拽物料组件至编辑器画布中并松开鼠标时,向 Schema 中追加数据,并将拖拽组件渲染至视图。
首先,我们监听物料组件拖动时,记录当前拖动的组件。通过 onDragStart 事件监听开始拖动,useRef 保存拖拽组件。
import { useRef } from 'react';
const currentMaterial = useRef(); // 记录当前拖拽的物料组件
const handleDragStart = (component) => {
currentMaterial.current = component;
}
<div
key={component.type}
draggable
onDragStart={() => handleDragStart(component)}
className="editor-left-item">
<span>{component.label}</span>
<div>{component.preview()}</div>
</div>
其次,监听画布区域的拖放事件,当拖动物料组件至画布上,并松开鼠标时,将物料组件渲染至画布中。
这里涉及到四个拖拽事件:
onDragEnter:拖拽元素移动到画布时,触发此事件;比如可以改变拖拽光标手势;onDragOver:拖拽元素停留在画布时,会持续触发此事件;需要通过它来取消默认事件,保证元素正常触发onDrop事件;onDragLeave:拖拽元素离开画布时,触发此事件;onDrop:对于画布来说,最重要的一个拖拽事件,当拖拽元素在画布上放置(松开鼠标)时触发此事件。
于是,我们很快可以写出:
const handleDragEnter = event => event.dataTransfer.dropEffect = 'move';
const handleDragOver = event => event.preventDefault();
const handleDragLeave = event => event.dataTransfer.dropEffect = 'none';
const handleDrop = event => {
...
}
<div className="editor-container">
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
id="canvas-container" style={{ ...schema.container }}>
编辑区 - 画布
</div>
</div>
在 handleDrop 中,我们会将拖拽的物料组件信息,添加到上文提到的 schema.blocks 中,blocks 中有了数据就可以渲染画布。
const handleDrop = event => {
const { offsetX, offsetY } = event.nativeEvent;
schema.blocks.push({
type: currentMaterial.current.type,
alignCenter: true, // 表示拖拽到画布后,基于鼠标位置居中展示
focus: false,
style: {
width: undefined,
height: undefined,
left: offsetX,
top: offsetY,
zIndex: 1,
},
});
setSchema(clone(schema));
currentMaterial.current = null;
}
我们来分析一下这里的逻辑:
位置信息:offsetX、offsetY 表示鼠标相对于事件源(画布)的 x 和 y 轴坐标,用于渲染物料组件在画布中的位置;层级信息:zIndex 用于今后实现置顶、置顶功能时使用;type:组件的类型,用于在画布渲染时,依据它从左侧物料区拿到对应的渲染组件;alignCenter:在我们初次将物料组件拖拽至画布上时,以当前鼠标的位置进行居中放置;focus:当画布中的元素被选中时,focus 值为 true;clone:是一个简单实现的深拷贝方法,用于帮助 state 更新视图,具体实现这里不做讲述。
Tip:由于 React 合成事件并未暴露 offsetX、offsetY 信息,所以需要定位到 event.nativeEvent 中读取位置信息。
有了 schema.blocks 数据源,我们就可以在画布上进行绘制了:
<div
...
id="canvas-container" style={{ ...schema.container }}>
{schema.blocks.map((block, index) => (
<Block key={index} block={block}></Block>
))}
</div>
Block 组件接收 block 作为 prop 实现画布元素渲染,我们看下具体实现:
import React, { useContext, useRef, useEffect, useReducer } from 'react';
import './style/block.scss';
import EditorContext from './context';
const Block = (props) => {
const [, forceUpdate] = useReducer(v => v + 1, 0);
const { config } = useContext(EditorContext);
const blockRef = useRef();
const { block, ...otherProps } = props;
useEffect(() => {
const { offsetWidth, offsetHeight } = blockRef.current;
const { style } = block;
if (block.alignCenter) {
style.left = style.left - offsetWidth / 2;
style.top = style.top - offsetHeight / 2;
delete block.alignCenter; // 删除,一次性的属性
forceUpdate();
}
}, [block]);
const blockStyle = {
top: block.style.top,
left: block.style.left,
zIndex: block.style.zIndex,
};
const component = config.componentMap[block.type];
const RenderComponent = component.render();
return (
<div
className={`editor-block`}
style={blockStyle}
ref={blockRef}
{...otherProps}>
{RenderComponent}
</div>
)
}
export default Block;
- block.alignCenter:借助 useEffect、useRef、useReducer 三个
React Hooks, 实现初次拖拽后画布元素居中展示; - block.style:每个
editor-block都是absolute元素,因此 top、left、zIndex 决定了画布元素的位置和层级; - block.type:根据左侧物料区
componentMap查找对应画布组件。
这里我们使用到了 config 中注册的物料组件信息,通过 useContext 在 EditorContext 中读取;为了便于一些数据在组件之间流动,采用 ReactContext。
我们需要创建一个 ReactContext context 对象,新建 src/context.js 文件:
import React from 'react';
const EditorContext = React.createContext(null);
export const Provider = EditorContext.Provider;
export default EditorContext;
在 Editor.js 中改造一下:
import { Provider } from './context';
function Editor() {
...
return (
<Provider value={{ config }}>
<div className="editor-wrapper">
...
</div>
</Provider>
);
}
六、画布元素获取焦点
画布元素被点击时,需要在 View 上设定一个形态,比如可以是一个边框,表示点击选中了此元素。
我们可以在 onClick 点击事件上做文章,但考虑到:按住元素进行拖动场景,决定统一使用 onMouseDown 事件。
const blocksFocusInfo = useCallback(() => {
let focus = [], unfocused = [];
schema.blocks.forEach(block => (block.focus ? focus : unfocused).push(block));
return { focus, unfocused };
}, [schema]);
const cleanBlocksFocus = (refresh) => {
schema.blocks.forEach(block => block.focus = false);
refresh && setSchema(clone(schema));
}
const handleMouseDown = (e, block) => {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
const { focus } = blocksFocusInfo();
// 当前只有一个被选中时,按住 shift 键不会切换 focus 状态
block.focus = focus.length <= 1 ? true : !block.focus;
} else {
if (!block.focus) {
cleanBlocksFocus();
block.focus = true;
}
}
setSchema(clone(schema));
}
<Block key={index} block={block} onMouseDown={e => handleMouseDown(e, block)}></Block>
- 单个场景:当元素处于未选中状态,点击后重置其他元素的选中状态,并让当前点击元素设置为选中状态;
- 多个场景:当用户按住了
shift键时,允许画布中存在多个选中的 block。
有了 focus 选中状态,就可以为 Block 元素添加 focus className:
// src/block.js
<div
className={`editor-block ${block.focus ? 'editor-block-focus' : ''}`}
...
</div>
// src/style/block.scss
.editor-block-focus{
user-select: none;
&::after{
border: 1px solid #1890ff;
}
}
到这里,我们完成了画布元素的选中/取消选中操作。不过我们可以再考虑全面一些:当点击画布空白区域时,将所有元素的选中状态置为 false:
const handleClickCanvas = event => {
event.stopPropagation();
cleanBlocksFocus(true);
}
<div className="editor-container">
<div
onMouseDown={handleClickCanvas}
...
</div>
</div>
七、画布元素拖动 / 移动
移动这里很好理解:
- 首先移动逻辑发生在按住
block元素时,因此可以在Block onMouseDown中做处理; - 移动前需要记录当前鼠标的位置信息,以及所有
focus block的当前所处位置; - 通过 document 监听移动事件,计算每次移动的新位置,去改变
focus block的 top、left 位置。
具体实现如下:
function Editor() {
...
const dragState = useRef({
startX: 0, // 移动前 x 轴位置
startY: 0, // 移动前 y 轴位置
startPos: [] // 移动前 所有 focus block 的位置存储
});
const handleMouseDown = (e, block) => {
...
// 进行移动
handleBlockMove(e);
}
const handleBlockMove = (e) => {
const { focus } = blocksFocusInfo();
// 1、记录鼠标拖动前的位置信息,以及所有选中元素的位置信息
dragState.current = {
startX: e.clientX,
startY: e.clientY,
startPos: focus.map(({ style: { top, left } }) => ({ top, left })),
}
const blockMouseMove = (e) => {
const { clientX: moveX, clientY: moveY } = e;
const durX = moveX - dragState.current.startX;
const durY = moveY - dragState.current.startY;
focus.forEach((block, index) => {
block.style.top = dragState.current.startPos[index].top + durY;
block.style.left = dragState.current.startPos[index].left + durX;
})
setSchema(clone(schema));
}
const blockMouseUp = () => {
document.removeEventListener('mousemove', blockMouseMove);
document.removeEventListener('mouseup', blockMouseUp);
}
// 2、通过 document 监听移动事件,计算每次移动的新位置,去改变 focus block 的 top 和 left
document.addEventListener('mousemove', blockMouseMove);
document.addEventListener('mouseup', blockMouseUp);
}
...
}
八、优化一下
细心的同学都发现了:每次都是执行 setSchema 去更新视图,但这个需要 clone 一份新的 schema 引用地址,setSchema 才会触发重渲染,这个消耗是比较大的;
其实更新的目录就是使得 Editor 组件进行重渲染,我们可以利用 React Hook useReducer 实现函数组件的 forceUpdate。
const [, forceUpdate] = useReducer(v => v + 1, 0);
需要更新视图时,直接这样使用:
forceUpdate();
最后
到这里,我们的程序搭建及编辑器画布的基础操作已经完成了,下节我们实现拖拽辅助线以及吸附功能,来一步步扩展编辑器功能。
如有不足之处,欢迎指正 👏 。