Implement a simple low-code editor from scratch

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:
Please add a picture description

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:
Please add a picture description
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: absoluteaccording to leftthe 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 setRightPanelTypeand setRightPanelElementIdto 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;
  };

  // ... 其他逻辑
}

elementIdIn order to realize that the right panel can edit data in real time, we first need to traverse the editor setDrawPanelDatadata 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, reactthe official drag and drop library, the document reference here: https://react-dnd.github.io/react-dnd/about

react-dndIn , dragand droptwo types of components are defined, then obviously, the components that need to be dragged in the left panel are dragcomponents, and the canvas is dropthe component.

For the components that need to be dragged on the left, we use react-dndthe provided useDraghooks 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 useDrophooks and getClientOffsetfunctions to get the dragged position, calculate leftthe and topvalues ​​of the new components, and then use setDatathe 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, jsonformat 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 schemadescribe 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 schemafor 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 propsparameters , but in fact, a complex low-code editor component has a deep hierarchy, and propsit is not realistic to use it. In this case, use globals reduxsuch as A library for data management would be more convenient.

Guess you like

Origin blog.csdn.net/u011748319/article/details/124799496