参考教程来自超 —— 级温柔可爱的越越老师:https://juejin.cn/post/7101148854371745823#heading-9
GitHub 地址:https://github.com/ERUIHNIYHBKBNF/mini-lowcode
# 概述
技术选型:React, TypeScript, React-Dnd
低代码编辑器,拖拖拽拽,生成页面,通过一定格式的数据存储,通过一定的方式解析成可见页面。
主要由三个部分组成:组件区、画布区、属性编辑区。
牛逼一点的比如 drawio 长这样:
<img src="https://cdn.jsdelivr.net/gh/ERUIHNIYHBKBNF/picapica@main/frontend/2022062701.webp" width="600px">
# 数据格式定义
自由约定就好了叭,这边简单用 json 来描述画布的内容。(/src/mock/editorData.json):
{ | |
"projectId": "xxx", | |
"projectName": "xxx", | |
"author": "xxx", | |
"data": [ | |
{ | |
"id": "1", // 每个组件都有个唯一标识 | |
"type": "text", // 组件类型,这个是文本框 | |
"data": "xxxxxx", // 文本组件的 data 就是文本内容 | |
"color": "#000000", // 以下是 css,这里使用的是 absolute 定位 | |
"size": "12px", | |
"width": "100px", | |
"height": "100px", | |
"left": "100px", | |
"top": "100px" | |
}, | |
{ | |
"id": "2", | |
"type": "image", | |
"data": "http://xxxxxxx", // 图片 / 视频组件的 data 就是对应资源的 url | |
"width": "100px", | |
"height": "100px", | |
"left": "100px", | |
"top": "100px" | |
}, | |
{ | |
"id": "3", | |
"type": "video", | |
"data": "http://xxxxxxx", | |
"width": "100px", | |
"height": "100px", | |
"left": "100px", | |
"top": "100px" | |
} | |
] | |
} |
# 项目搭建
简单绘制一下页面,分为左中右三个部分:
<img src="https://cdn.jsdelivr.net/gh/ERUIHNIYHBKBNF/picapica@main/frontend/2022062703.webp" width="750px">
文件结构也很普通 qwq:
<img src="https://cdn.jsdelivr.net/gh/ERUIHNIYHBKBNF/picapica@main/frontend/2022062702.webp" width="500px">
# 编写一个简单的可拖动组件
React DnD,react 官方开发的拖拽库,https://react-dnd.github.io/react-dnd/
搬运文档里提供的一些概念:
Items and Types:React DnD 使用数据,而不是视图
When you drag something across the screen, we don't say that a component, or a DOM node is being dragged. Instead, we say that an item of a certain type is being dragged.
item:使用数据(一般是 js 对象)来描述一个可拖动对象,类似上面 json
type:拖动元素的类型,例如上方 json 中的 text, image 等
drag sources:可拖动的 item
drop targets:可以被放置 item 的元素
The types let you specify which drag sources and drop targets are compatible.
Monitors:记录 dnd 相关组件的 state,例如正在拖动 / 已放置。通过定义一个 collect 函数,允许开发者在 dnd 状态改变时,即时修改组件的 props
例如这样定义一个 collect 来即时更新一个棋子的部分 props:
collect: (monitor) => ({
highlighted: monitor.canDrop(), // 拖动时高亮
hovered: monitor.isOver(), // 鼠标指向时表现 hover
})
安装依赖:
npm install react-dnd react-dnd-html5-backend |
在根组件中引入 Provider:
<DndProvider backend={HTML5Backend}> | |
<div className="App">...</div> | |
</DndProvider> |
补充定义一些组件类型(/src/consts/types.ts):
export enum COMPONENT_TYPE { | |
TEXT = 'text', | |
VIDEO = 'video', | |
IMAGE = 'image', | |
AUDIO = 'audio', | |
CARD = 'card', | |
} |
编写一个可拖动的组件(/src/components/textComponent.tsx):
import React from 'react'; | |
import { useDrag } from 'react-dnd'; | |
import { COMPONENT_TYPE } from '../../consts/types'; | |
import './style.css'; | |
export default function TextComponent() { | |
const [, drag] = useDrag(() => ({ | |
type: COMPONENT_TYPE.TEXT, | |
collect: monitor => ({ | |
isDragging: !!monitor.isDragging(), | |
}), | |
})); | |
return ( | |
<div className="textComponent" ref={drag}> | |
文字组件 | |
</div> | |
); | |
} |
在左侧面板中引入这个可拖动组件,就可以在整个窗口范围内拖动了。
# 通过中间画板渲染元素
先添加一组 mock 数据假装是已保存的画布(/src/mock/drawData/json):
{ | |
"data": [ | |
{ | |
"id": "text-1", | |
"type": "text", | |
"data": "我是 1 号文字", | |
"color": "#FF0000", | |
"size": "12px", | |
"width": "100px", | |
"height": "20px", | |
"left": "100px", | |
"top": "100p" | |
}, | |
{ | |
"id": "text-2", | |
"type": "text", | |
"data": "我是 2 号文字", | |
"color": "#00FF00", | |
"size": "12px", | |
"width": "100px", | |
"height": "20px", | |
"left": "100px", | |
"top": "150p" | |
}, | |
{ | |
"id": "text-3", | |
"type": "text", | |
"data": "我是 3 号文字", | |
"color": "#0000FF", | |
"size": "12px", | |
"width": "100px", | |
"height": "20px", | |
"left": "100px", | |
"top": "200p" | |
} | |
] | |
} |
添加一些类型,用于标识右侧面板展示哪些配置项(/src/consts/types.ts):
export enum RIGHT_PANEL_TYPE { | |
NONE = 'none', | |
TEXT = 'text', | |
VIDEO = 'video', | |
IMAGE = 'image', | |
CARD = 'card', | |
} |
在 App.tsx 中新增两个 state,并将其传给中间画板:
const [drawPanelData, setDrawPanelData] = useState([...drawData.data]); | |
const [rightPanelType, setRightPanelType] = useState(RIGHT_PANEL_TYPE.TEXT); | |
<MidPanel data={drawPanelData} setRightPanelType={setRightPanelType} /> |
在 MidPanel 中渲染元素(核心就是一个 generateContent)(/src/pages/midPanel):
import React from "react"; | |
import { RIGHT_PANEL_TYPE } from "../../consts/types"; | |
import './style.css'; | |
type DrawPanelProps = { | |
data: any; | |
setRightPanelType: Function; | |
} | |
export default function MidPanel({data, setRightPanelType}: DrawPanelProps) { | |
const generateContent = () => { | |
const ret = []; | |
for (const item of data) { | |
switch (item.type) { | |
case 'text': | |
ret.push( | |
<div | |
key={item.id} | |
onClick={() => { | |
console.log(`clicked: item ${item.id}`); | |
setRightPanelType(RIGHT_PANEL_TYPE.TEXT); | |
}} | |
style=<!--swig0--> | |
> | |
{item.data} | |
</div> | |
); | |
break; | |
} | |
} | |
return ret; | |
} | |
return ( | |
<div className="midPanel"> | |
<div> | |
<h1>MidPanel</h1> | |
</div> | |
{/* 顺带把这个盒子定位改成 relative,方便内部组件用 (absolute) height 和 left 确认位置,定住宽高方便之后摆放 */} | |
<div> | |
{generateContent()} | |
</div> | |
</div> | |
); | |
} |
# 右侧属性编辑面板
首先要知道编辑的是哪个元素,在 app.tsx 和 midPanel.tsx 中新增 state 相关:
const [rightRanelElementId, setRightRanelElementId] = useState(''); | |
// 对应组件内也做一些修改,这里不放代码了 qwq | |
<MidPanel | |
data={drawPanelData} | |
setRightPanelType={setRightPanelType} | |
setRightRanelElementId={setRightRanelElementId} | |
/> | |
<RightPanel | |
type={rightPanelType} | |
data={drawPanelData} | |
elementId={rightRanelElementId} | |
setDrawPanelData={setDrawPanelData} | |
/> |
新增 type 方便写 ts(/src/consts/types):
export type ElementType = { | |
id: string; | |
type: string; | |
[prop: string]: any; | |
}; |
然后开始编写右侧面板,类似中间面板的方式(/src/pages/rightPanel):
export default function RightPanel({ | |
type, | |
data, | |
elementId, | |
setDrawPanelData | |
}: RightPanelProps) { | |
// 找到要修改的元素以将其原始值展示到右侧 | |
const findCurrentElement = (id: string) => { | |
return data.find((item: ElementType) => item.id === id); | |
} | |
// 修改 id 元素 key 属性 newData 值 | |
const changeElementData = (id: string, key: string, newData: any) => { | |
const element = findCurrentElement(id); | |
if (element) { | |
element[key] = newData; | |
setDrawPanelData([...data]); | |
} | |
} | |
const generateRightPanel = () => { | |
if (type === RIGHT_PANEL_TYPE.NONE) { | |
return <div>属性编辑区</div>; | |
} | |
switch (type) { | |
case RIGHT_PANEL_TYPE.TEXT: | |
if (!elementData) { | |
return <div>属性编辑区</div>; | |
} | |
const inputDomObject: Array<HTMLInputElement> = []; // 保存输入框的 DOM 便于更新时获取其值 | |
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> | |
{/* 各项属性表单... */} | |
<br /> | |
<button | |
onClick={() => { | |
changeElementData(elementId, 'data', inputDomObject[0].value); | |
// ...... | |
}} | |
> | |
确定 | |
</button> | |
</div> | |
); | |
} | |
}; | |
return ( | |
<div className="rightPanel"> | |
<div> | |
<h1>RightPanel</h1> | |
</div> | |
<div className="rightFormContainer"> | |
{generateRightPanel()} | |
</div> | |
</div> | |
); | |
} |
基本的样子已经有了:
<img src="https://cdn.jsdelivr.net/gh/ERUIHNIYHBKBNF/picapica@main/frontend/2022062801.webp" width="800px">
# 中间接收并生成元素
掏出之前写的(/src/components/testComponent)这个地方,可以发现允许 drag 的同时指定了 type:
const [_, drag] = useDrag(() => ({ | |
type: COMPONENT_TYPE.TEXT | |
})); |
在 midPanel 中 useDrop,接受 TEXT 类型元素,并计算相对位置保存进 data:
const containerRef = React.useRef<HTMLDivElement>(null); // 分给 midPanel 容器便于计算坐标 | |
const [, drop] = useDrop(() => ({ | |
accept: COMPONENT_TYPE.TEXT, //drop 接受的 type | |
drop: (_, monitor) => { | |
const { x, y } = monitor.getSourceClientOffset()!; // 相对屏幕左上角的位置 | |
// 计算相对容器左上角的位置 | |
const [currentX, currentY] = [x - containerRef.current!.offsetLeft, y - 75]; | |
setData([ | |
...data, | |
{ | |
id: `text-${Date.now()}`, | |
type: 'text', | |
data: '我是新建的文字', | |
color: '#000000', | |
size: '12px', | |
width: '100px', | |
height: '20px', | |
left: `${currentX}px`, | |
top: `${currentY}px` | |
} | |
]); | |
} | |
})); | |
// 以及对要接受 drop 的元素指定 ref 为 drop | |
<div className="midItemsContainer" ref={drop}> | |
{generateContent()} | |
</div> |
注意为 App 下的 MidPanel 元素添加 key 值:这样新增元素时才能让 react 认为存在更新,这里直接用 item 的个数作为 key 值。
<MidPanel | |
key={`${drawPanelData.length}`} | |
data={drawPanelData} | |
setRightPanelType={setRightPanelType} | |
setRightRanelElementId={setRightRanelElementId} | |
setData={setDrawPanelData} | |
/> |
# 可拖动调整位置
以上操作就可以完成一个简易的低代码编辑器啦,不过我们仍然可以利用这个小项目来练习一下刚刚所学的知识,例如再写个图片组件、布局组件之类的,或者让中间已经放置的元素仍然可以被拖动。毕竟放上不能再挪位置就很不合理 qwq
我们先把 midPanel 渲染的文章组件拿出来,并且使它可拖动(/src/pages/midPanel):
type TextComponentDropedProps = { | |
item: ElementType; | |
setRightPanelType: Function; | |
setRightRanelElementId: Function; | |
}; | |
function TextComponentDroped({ | |
item, | |
setRightPanelType, | |
setRightRanelElementId | |
}: TextComponentDropedProps) { | |
const [, drag] = useDrag(() => ({ | |
type: COMPONENT_TYPE.TEXT_DROPED, | |
item: { id: item.id }, // 这里把 id 传进去以便后面 drop 接收 | |
})); | |
return ( | |
<div | |
onClick={() => { | |
console.log(`clicked: item ${item.id}`); | |
setRightPanelType(RIGHT_PANEL_TYPE.TEXT); | |
setRightRanelElementId(item.id); | |
}} | |
style=<!--swig1--> | |
ref={drag} | |
> | |
{item.data} | |
</div> | |
); | |
} |
仍然是在 midPanel 中接收,但这种类型的元素 drop 时只是去更新 data 而不是创建新的元素(/src/pages/midPanel):
const [, drop] = useDrop(() => ({ | |
accept: [COMPONENT_TYPE.TEXT, COMPONENT_TYPE.TEXT_DROPED], //drop 可接受的 type 中增添新的类型 | |
drop: (_, monitor) => { | |
const { x, y } = monitor.getSourceClientOffset()!; | |
// 计算相对容器左上角的位置 | |
const [currentX, currentY] = [x - containerRef.current!.offsetLeft, y - 75]; | |
switch (monitor.getItemType()) { | |
case COMPONENT_TYPE.TEXT: | |
setData([ | |
...data, | |
{ | |
id: `text-${Date.now()}`, | |
type: 'text', | |
data: '我是新建的文字', | |
color: '#000000', | |
size: '12px', | |
width: '100px', | |
height: '20px', | |
left: `${currentX}px`, | |
top: `${currentY}px` | |
} | |
]); | |
return; | |
case COMPONENT_TYPE.TEXT_DROPED: | |
// 这里直接使用 leftPanel 的 changeElementData | |
changeElementData((monitor.getItem() as { id: string }).id, 'left', `${currentX}px`); | |
changeElementData((monitor.getItem() as { id: string }).id, 'top', `${currentY}px`); | |
return; | |
} | |
} | |
})); |
这样就 ok 啦 qwq