1. Write in front
As a PaaS software that can greatly improve development efficiency, low-code editors have been sought after by major companies and investors in recent years. For our front-end developers, the editor is also one of the few development scenarios with deep front-end technology depth.
Through this article, you can learn how to build the simplest low-code editor based on the React technology stack, and how to implement some key functions.
The sample code of this article has been open sourced on GitHub, and friends in need can get it by themselves: https://github.com/shadowings-zy/mini-editor
Simply preview the demo of this editor:
2. Directory
- Editor function split
- Definition of editor data format
- project code structure
- Implementation of key logic (canvas rendering, attribute linkage, dragging components)
- Interact with the background
- Points that can also be optimized
3. Editor function splitting
Let’s start with a prototype diagram:
For most low-code editors, they are composed of three parts: "component area", "canvas area" and "property editing area".
- The component area is responsible for displaying draggable components and the hierarchical relationship between components.
- The canvas area is responsible for rendering the dragged components and visually displaying them.
- The attribute editing area is responsible for editing the attributes of the selected component.
Based on the responsibilities of these three areas, we can easily design the functions that these three areas need to achieve:
- For the component area, we need to ensure that the components are draggable and the components can interact with the canvas area
- For the canvas area, we need to first abstract a data format for displaying "what components are in the canvas area", and then the canvas area can render the corresponding components according to this data format. Secondly, we also need to realize the interaction between the dragged component and the canvas, and the interaction with the attribute editing area after the component is selected.
- For the attribute editing area, we need to handle the linkage logic with the corresponding component after the attribute is changed.
4. Definition of editor data format
The data format at the bottom of the editor is the most important thing to develop a low-code editor. The canvas area will render the canvas according to this data, and the dragging and dropping of components and the configuration of component properties are actually changes to this data.
And back to our editor itself, we can use data in json format to abstract the contents of the editor canvas, like this:
{
"projectId": "xxx", // 项目 ID
"projectName": "xxx", // 项目名称
"author": "xxx", // 项目作者
"data": [
// 画布组件配置
{
"id": "xxx", // 组件 ID
"type": "text", // 组件类型
"data": "xxxxxx", // 文字内容
"color": "#000000", // 文字颜色
"size": "12px", // 文字大小
"width": "100px", // 容器宽度
"height": "100px", // 容器高度
"left": "100px", // 容器左边距
"top": "100px" // 容器上边距
},
{
"id": "xxx", // 组件 ID
"type": "image", // 组件类型
"data": "http://xxxxxxx", // 图片 url
"width": "100px", // 容器宽度
"height": "100px", // 容器高度
"left": "100px", // 容器左边距
"top": "100px" // 容器上边距
},
{
"id": "xxx", // 组件 ID
"type": "video", // 组件类型
"data": "http://xxxxxxx", // 视频 url
"width": "100px", // 容器宽度
"height": "100px", // 容器高度
"left": "100px", // 容器左边距
"top": "100px" // 容器上边距
}
]
}
After defining the data structure, "component property editing" and "drag and drop to add components" are actually adding, deleting, and modifying the data field in the json data, and the canvas area will also use this field to render the components in the canvas.
5. Project code structure
The overall code structure is as follows:
.
├── package
│ ├── client # 前端页面
│ │ ├── build # webpack 打包配置
│ │ │ ├── webpack.base.js
│ │ │ ├── webpack.dev.js
│ │ │ └── webpack.prod.js
│ │ ├── components # 前端组件
│ │ │ ├── textComponent # 组件区中的「文字组件」
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ └── textPanel # 「文字组件」对应的属性编辑组件
│ │ │ ├── index.tsx
│ │ │ └── style.css
│ │ ├── constants # 一些常量
│ │ │ └── index.ts
│ │ ├── index.html
│ │ ├── index.tsx
│ │ ├── pages # 前端页面
│ │ │ ├── app # 根组件
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ ├── drawPanel # 画布区
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ ├── leftPanel # 左侧组件区
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ └── rightPanel # 右侧属性编辑区
│ │ │ ├── index.tsx
│ │ │ └── style.css
│ │ ├── style.css
│ │ └── tsconfig.json
│ └── server # 后端代码
│ ├── app.ts # 后端逻辑
│ ├── config # 后端配置
│ │ ├── dev.ts
│ │ ├── index.ts
│ │ └── prod.ts
│ ├── constants.ts # 一些常量
│ └── tsconfig.json
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json
6. Realization of Key Logic
Before sorting out the key logic, we have to sort out what data our editor components need to maintain.
- The first is the editor data. The canvas needs to render content according to the editor data, and adding components and modifying properties are essentially changes to this data.
- The second is the type of the right panel. Editing different components requires different types of editing items.
- In addition, there is the currently selected component id, and the changes in the properties panel on the right will take effect on the current component id.
So we maintain these data under the root component and pass them to other subcomponents with props, the code is as follows:
import DrawPanel from "../drawPanel"; // 画布
import LeftPanel from "../leftPanel"; // 左侧组件面板
import RightPanel from "../rightPanel"; // 右侧属性编辑面板
export default function App() {
const [drawPanelData, setDrawPanelData] = useState([]); // 编辑器数据
const [rightPanelType, setRightPanelType] = useState(RIGHT_PANEL_TYPE.NONE); // 右侧属性面板类型
const [rightPanelElementId, setRightPanelElementId] = useState(""); // 右侧属性面板编辑的 id
return (
<div className="flex-row-space-between app">
<LeftPanel data={
drawPanelData}></LeftPanel>
<DrawPanel
data={
drawPanelData}
setData={
setDrawPanelData}
setRightPanelType={
setRightPanelType}
setRightPanelElementId={
setRightPanelElementId}
></DrawPanel>
<RightPanel
type={
rightPanelType}
data={
drawPanelData}
elementId={
rightPanelElementId}
setDrawPanelData={
setDrawPanelData}
></RightPanel>
</div>
);
}
After defining these data, let's explain the implementation of the key logic.
6-1. Canvas rendering
First, let's take a look at the implementation of the canvas rendering logic:
Here we need to adjust the layout of the canvas area to position: relative
, and then set the layout of each component to , so that we can locate the position of the component on the canvas position: absolute
according to left
the and .top
Then it traverses the editor data and renders the corresponding components to the canvas.
The specific code is as follows:
// package/client/pages/drawPanel/index.tsx
interface IDrawPanelProps {
data: any; // 将编辑器数据作为 props 传入组件中
}
export default function DrawPanel(props: IDrawPanelProps) {
const {
data } = props;
const generateContent = () => {
const output = [];
// 遍历编辑器数据并渲染画布
for (const item of data) {
if (item.type === COMPONENT_TYPE.TEXT) {
output.push(
<div
key={
item.id}
style={
{
color: item.color,
fontSize: item.size,
width: item.width,
height: item.height,
left: item.left,
top: item.top,
position: "absolute",
backgroundColor: "#bbbbbb",
}}
>
{
item.data}
</div>
);
}
}
return output;
};
return (
<div
className="draw-panel"
ref={
drop}
style={
{
position: "relative",
}}
>
{
generateContent()}
</div>
);
}
6-2. Attribute linkage
Next, in order to realize attribute linkage, we need to implement the following things:
1. Add a click event to the component on the canvas so that it can set the content of the property editing panel on the right when it is clicked.
2. When editing component properties in the property editing panel on the right, it is necessary to be able to modify the data corresponding to the target component in the editor data, and then the canvas area is rendered according to the new editor data.
In order to achieve the first point, we need to add a click event to each rendered component in the canvas component, and use setRightPanelType
and setRightPanelElementId
to set the corresponding selected element, the code is as follows:
// package/client/pages/drawPanel/index.tsx
export default function DrawPanel(props: IDrawPanelProps) {
const {
data, setRightPanelType, setRightPanelElementId } = props;
const generateContent = () => {
const output = [];
for (const item of data) {
if (item.type === COMPONENT_TYPE.TEXT) {
output.push(
<div
key={
item.id}
style={
{
color: item.color,
fontSize: item.size,
width: item.width,
height: item.height,
left: item.left,
top: item.top,
position: 'absolute',
backgroundColor: '#bbbbbb'
}}
+ // 在这里添加点击事件
+ onClick={
() => {
+ setRightPanelType(RIGHT_PANEL_TYPE.TEXT);
+ setRightPanelElementId(item.id);
+ }}
>
{
item.data}
</div>
);
}
}
return output;
};
// ... 其他逻辑
}
elementId
In order to realize that the right panel can edit data in real time, we first need to traverse the editor setDrawPanelData
data according to the input , get the item to be modified, and then obtain the corresponding attribute change value, and finally use to modify it. The specific code is as follows:
interface IRigthPanelProps {
type: RIGHT_PANEL_TYPE;
data: any;
elementId: string;
setDrawPanelData: Function;
}
export default function RightPanel(props: IRigthPanelProps) {
const {
type, data, elementId, setDrawPanelData } = props;
const findCurrentElement = (id: string) => {
for (const item of data) {
if (item.id === id) {
return item;
}
}
return undefined;
};
const findCurrentElementAndChangeData = (
id: string,
key: string,
changedData: any
) => {
for (let item of data) {
if (item.id === id) {
item[key] = changedData;
}
}
setDrawPanelData([...data]);
};
const generateRightPanel = () => {
if (type === RIGHT_PANEL_TYPE.NONE) {
return <div>未选中元素</div>;
} else if (type === RIGHT_PANEL_TYPE.TEXT) {
const elementData = findCurrentElement(elementId);
const inputDomObject = [];
return (
<div key={
elementId}>
<div>文字元素</div>
<br />
<div className="flex-row-space-between text-config-item">
<div>文字内容:</div>
<input
defaultValue={
elementData.data}
ref={
(element) => {
inputDomObject[0] = element;
}}
type="text"
></input>
</div>
<div className="flex-row-space-between text-config-item">
<div>文字颜色:</div>
<input
defaultValue={
elementData.color}
ref={
(element) => {
inputDomObject[1] = element;
}}
type="text"
></input>
</div>
<div className="flex-row-space-between text-config-item">
<div>文字大小:</div>
<input
defaultValue={
elementData.size}
ref={
(element) => {
inputDomObject[2] = element;
}}
type="text"
></input>
</div>
<div className="flex-row-space-between text-config-item">
<div>width:</div>
<input
defaultValue={
elementData.width}
ref={
(element) => {
inputDomObject[3] = element;
}}
type="text"
></input>
</div>
<div className="flex-row-space-between text-config-item">
<div>height:</div>
<input
defaultValue={
elementData.height}
ref={
(element) => {
inputDomObject[4] = element;
}}
type="text"
></input>
</div>
<div className="flex-row-space-between text-config-item">
<div>top:</div>
<input
defaultValue={
elementData.top}
ref={
(element) => {
inputDomObject[5] = element;
}}
type="text"
></input>
</div>
<div className="flex-row-space-between text-config-item">
<div>left:</div>
<input
defaultValue={
elementData.left}
ref={
(element) => {
inputDomObject[6] = element;
}}
type="text"
></input>
</div>
<br />
<button
onClick={
() => {
findCurrentElementAndChangeData(
elementId,
"data",
inputDomObject[0].value
);
findCurrentElementAndChangeData(
elementId,
"color",
inputDomObject[1].value
);
findCurrentElementAndChangeData(
elementId,
"size",
inputDomObject[2].value
);
findCurrentElementAndChangeData(
elementId,
"width",
inputDomObject[3].value
);
findCurrentElementAndChangeData(
elementId,
"height",
inputDomObject[4].value
);
findCurrentElementAndChangeData(
elementId,
"top",
inputDomObject[5].value
);
findCurrentElementAndChangeData(
elementId,
"left",
inputDomObject[6].value
);
}}
>
确定
</button>
</div>
);
}
};
return <div className="right-panel">{
generateRightPanel()}</div>;
}
6-3. Drag and drop components
Finally, it comes to the most important thing, how to implement drag and drop, which is used here react-dnd
, react
the official drag and drop library, the document reference here: https://react-dnd.github.io/react-dnd/about
react-dnd
In , drag
and drop
two types of components are defined, then obviously, the components that need to be dragged in the left panel are drag
components, and the canvas is drop
the component.
For the components that need to be dragged on the left, we use react-dnd
the provided useDrag
hooks to make them draggable, the code is as follows:
// package/client/components/textComponent/index.tsx
export default function TextComponent() {
const [_, drag] = useDrag(() => ({
type: COMPONENT_TYPE.TEXT,
}));
return (
<div className="text-component" ref={
drag}>
文字组件
</div>
);
}
For the canvas, we use useDrop
hooks and getClientOffset
functions to get the dragged position, calculate left
the and top
values of the new components, and then use setData
the settings editor data, the code is as follows:
export default function DrawPanel(props: IDrawPanelProps) {
const {
data, setRightPanelType, setRightPanelElementId, setData } = props;
const [, drop] = useDrop(() => ({
accept: COMPONENT_TYPE.TEXT,
drop: (_, monitor) => {
const {
x, y } = monitor.getClientOffset();
const currentX = x - 310;
const currentY = y - 20;
setData([
...data,
{
id: `text-${
data.length + 1}`,
type: "text",
data: "我是新建的文字",
color: "#000000",
size: "12px",
width: "100px",
height: "20px",
left: `${
currentX}px`,
top: `${
currentY}px`,
},
]);
},
}));
// ... 其他逻辑
}
Seven, interact with the background
After we have implemented the logic of the editor, the logic of interacting with the background is quite simple. It is enough to initiate a request to the background and save/retrieve the json data of the editor. Here, the logic of saving data to the background is simply implemented. The code is as follows :
import axios from "axios";
export default function LeftPanel(props: ILeftPanelProps) {
const {
data } = props;
return (
<div className="left-panel">
<div className="component-list">
<TextComponent></TextComponent>
</div>
<button
className="save-button"
onClick={
() => {
console.log("save:", data);
axios
.post("/api/save", {
drawPanelData: data })
.then((res) => {
console.log("res:", res);
})
.catch((err) => {
console.log("err:", err);
});
}}
>
保存到后台
</button>
</div>
);
}
After the background receives the data, it can be stored in the database.
import Koa from "koa";
import Router from "koa-router";
import koaStatic from "koa-static";
import koaBody from "koa-body";
import {
config } from "./config";
import {
PORT } from "./constants";
const app = new Koa();
app.use(koaBody());
const router = new Router();
router.get("/api", async (ctx, next) => {
ctx.body = {
message: "Hello World" };
await next();
});
router.post("/api/save", async (ctx, next) => {
console.log("save:", ctx.request.body);
// ...储存到数据库
ctx.body = {
message: "Save data successful",
receivedData: ctx.request.body,
};
await next();
});
app.use(router.routes());
app.use(router.allowedMethods());
app.use(koaStatic(config.staticFilePath));
app.listen(PORT, () => {
console.log(`Server listening on port ${
PORT}`);
});
8. Points that can be explored in depth
Through the above steps, we can implement the simplest low-code editor, but there are still many technical points that can be explored in depth. I will list some of them and the solutions below.
8-1. How to implement component nesting?
For component nesting, we need to modify the operation logic for editor data (that is, json
format data), from the original "array insertion" to an operation for a certain level. At the same time, the logic of component traversal must also be changed.
8-2. Can a higher-level attribute editing component be abstracted?
Most editors in the industry actually make such a layer of abstraction. For different attribute editing components, they will be used to schema
describe the editable items of this editing component and the data that can be changed corresponding to this editing item. The attribute editing component is actually used to consume this schema
for rendering.
8-3. How to upload a relatively large file like a video to the server?
This involves uploading files in pieces and back-end combined storage. You can see this project I implemented before: https://github.com/shadowings-zy/easy-file-uploader
8-4. A better global data management solution?
Because the implementation is relatively simple, this article puts all the data under the root component, and then passes it as props
parameters , but in fact, a complex low-code editor component has a deep hierarchy, and props
it is not realistic to use it. In this case, use globals redux
such as A library for data management would be more convenient.