低代码编辑器项目实战
学习目标
- 掌握 继续阅读
这节开始,我们做一个实战项目:低代码编辑器
这种编辑器都差不多,比如百度开源的 amis:
左边是物料区,中间是画布区,右边是属性编辑区。
可以从物料区拖拽组件到中间的画布区,来可视化搭建页面:
画布区的组件可以选中之后,在属性编辑区修改属性:
左边可以看到组件的大纲视图,用树形展示组件嵌套结构:
也可以直接看生成的 json 结构:
可以看到,json 的嵌套结构和页面里组件的结构一致,并且 json 对象的属性也是在属性编辑区编辑后的。
所以说,整个低代码编辑器就是围绕这个 json 来的。
从物料区拖拽组件到画布区,其实就是在 json 的某一层级加了一个组件对象。
选中组件在右侧编辑属性,其实就是修改 json 里某个组件对象的属性。
大纲就是把这个 json 用树形展示。
你从 json 的角度来回想一下低代码编辑器的拖拽组件到画布、编辑属性、查看大纲这些功能,是不是原理就很容易想通了?
没错,这就是低代码编辑器的核心,就是一个 json。
拖拽也是低代码编辑器的一个难点,用 react-dnd 做就行。
但交互方式是次要的,比如移动端页面的低代码编辑器,可能不需要拖拽,点击就会添加到画布:
这种不需要拖拽的是低代码编辑器么?
明显也是。所以说,拖拽不是低代码编辑器必须的。
理解低代码编辑器的核心就是 json 数据结构,不同交互只是修改这个 json 不同部分就行。
下面我们自己来写一个:
npx create-vite lowcode-editor安装依赖,把项目跑起来:
npm install
npm run dev改下 main.tsx:
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)新建 src/editor/index.tsx
export default function LowcodeEditor() {
return <div>LowcodeEditor</div>
}在 App.tsx 引入下:
import LowcodeEditor from './editor';
function App() {
return (
<LowcodeEditor/>
)
}
export default App
按照 tailwind 文档里的步骤安装 tailwind:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p会生成 tailwind 和 postcss 配置文件:
修改下 content 配置,也就是从哪里提取 className:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}tailwind 会提取 className 之后按需生成最终的 css。
改下 index.css 引入 tailwind 基础样式:
@tailwind base;
@tailwind components;
@tailwind utilities;在 main.tsx 里引入:
如果你没安装 tailwind 插件,需要安装一下:
这样在写代码的时候就会提示 className 和对应的样式值:
不知道 className 叫啥的样式,还可以在 tailwind 文档里搜:
接下来写布局:
我们用 allotment 实现可拖动改变大小的 pane:
安装这个包:
npm install --save allotment改下 LowcodeEditor:
import { Allotment } from "allotment";
import 'allotment/dist/style.css';
export default function ReactPlayground() {
return <div className='h-[100vh] flex flex-col'>
<div className=''>
Header
</div>
<Allotment>
<Allotment.Pane preferredSize={240} maxSize={300} minSize={200}>
Materail
</Allotment.Pane>
<Allotment.Pane>
EditArea
</Allotment.Pane>
<Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
Setting
</Allotment.Pane>
</Allotment>
</div>
}引入 Allotment 组件和样式。
设置左右两个 pane 的初始 size,最大最小 size。
h-[任意数值] 是 tailwind 支持的样式写法,就是 height: 任意数值 的意思。
h-[100vh] 就是 height: 100vh
然后设置 flex、flex-col
看下样式:
没问题。
左右两边是可以拖拽改变大小的:
初始 size、最大、最小 size 都和我们设置的一样。
然后写下 header 的样式。
高度 60px、用 flex 布局,竖直居中,有一个底部 border
h-[60px] flex items-center border-b-[1px] border-[#000]没啥问题。
然后换成具体的组件:
import { Allotment } from "allotment";
import 'allotment/dist/style.css';
import { Header } from "./components/Header";
import { EditArea } from "./components/EditArea";
import { Setting } from "./components/Setting";
import { Material } from "./components/Material";
export default function ReactPlayground() {
return <div className='h-[100vh] flex flex-col'>
<div className='h-[60px] flex items-center border-b-[1px] border-[#000]'>
<Header />
</div>
<Allotment>
<Allotment.Pane preferredSize={240} maxSize={300} minSize={200}>
<Material />
</Allotment.Pane>
<Allotment.Pane>
<EditArea />
</Allotment.Pane>
<Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
<Setting />
</Allotment.Pane>
</Allotment>
</div>
}分别写下这几个组件:
editor/components/Header.tsx
export function Header() {
return <div>Header</div>
}editor/components/Material.tsx
export function Material() {
return <div>Material</div>
}editor/components/EditArea.tsx
export function EditArea() {
return <div>EditArea</div>
}editor/components/Setting.tsx
export function Setting() {
return <div>Setting</div>
}布局写完了,接下来可以正式来写逻辑了。
这节先来写下低代码编辑器核心的数据结构。
我们不用 Context 保存全局数据了,用 zustand 来做。
npm install --save zustand前面做 todolist 案例用过 zustand:
声明 State、Action 的类型,然后在 create 方法里声明 state、action 就行。
创建 editor/stores/components.tsx,在这里保存全局的那个组件 json:
import {create} from 'zustand';
export interface Component {
id: number;
name: string;
props: any;
children?: Component[];
parentId?: number;
}
interface State {
components: Component[];
}
interface Action {
addComponent: (component: Component, parentId?: number) => void;
deleteComponent: (componentId: number) => void;
updateComponentProps: (componentId: number, props: any) => void;
}
export const useComponetsStore = create<State & Action>(
((set, get) => ({
components: [
{
id: 1,
name: 'Page',
props: {},
desc: '页面',
}
],
addComponent: (component, parentId) =>
set((state) => {
if (parentId) {
const parentComponent = getComponentById(
parentId,
state.components
);
if (parentComponent) {
if (parentComponent.children) {
parentComponent.children.push(component);
} else {
parentComponent.children = [component];
}
}
component.parentId = parentId;
return {components: [...state.components]};
}
return {components: [...state.components, component]};
}),
deleteComponent: (componentId) => {
if (!componentId) return;
const component = getComponentById(componentId, get().components);
if (component?.parentId) {
const parentComponent = getComponentById(
component.parentId,
get().components
);
if (parentComponent) {
parentComponent.children = parentComponent?.children?.filter(
(item) => item.id !== +componentId
);
set({components: [...get().components]});
}
}
},
updateComponentProps: (componentId, props) =>
set((state) => {
const component = getComponentById(componentId, state.components);
if (component) {
component.props = {...component.props, ...props};
return {components: [...state.components]};
}
return {components: [...state.components]};
}),
})
)
);
export function getComponentById(
id: number | null,
components: Component[]
): Component | null {
if (!id) return null;
for (const component of components) {
if (component.id == id) return component;
if (component.children && component.children.length > 0) {
const result = getComponentById(id, component.children);
if (result !== null) return result;
}
}
return null;
}我们从上到下来看下:
store 里保存着 components 组件树,它是一个用 children 属性连接起来的树形结构。
我们定义了每个 Component 节点的类型,有 id、name、props 属性,然后通过 chiildren、parentId 关联父子节点。
此外,定义了 add、delete、update 的增删改方法,用来修改 components 组件树。
这是一个树形结构,想要增删改都要先找到 parent 节点,我们实现了查找方法:
树形结构中查找节点,自然是通过递归。
如果节点 id 是查找的目标 id 就返回当前组件,否则遍历 children 递归查找。
之后就可以实现增删改方法了:
新增会传入 parentId,在哪个节点下新增:
查找到 parent 之后,在 children 里添加一个 component,并把 parentId 指向这个 parent。
没查到就直接放在 components 下。
删除则是找到这个节点的 parent,在 parent.children 里删除当前节点:
修改 props 也是找到目标 component,修改属性:
这样,components 和它的增删改查方法就都定义好了。
这就是我们前面分析的核心数据结构。
有了这个就能实现低代码编辑器的大多数功能了。
不信?
我们试一下:
比如我们拖拽一个容器组件进来:
是不是就是在 components 下新加了一个组件。
模拟实现下:
import { useEffect } from "react";
import { useComponetsStore } from "../../stores/components"
export function EditArea() {
const {components, addComponent} = useComponetsStore();
useEffect(()=> {
addComponent({
id: 222,
name: 'Container',
props: {},
children: []
}, 1);
}, []);
return <div>
<pre>
{
JSON.stringify(components, null, 2)
}
</pre>
</div>
}在 EditArea 组件里,调用 store 里的 addComponent 添加一个组件。
然后把 components 组件树渲染出来:
可以看到,Page 下多了一个 Container 组件。
然后在 Container 下拖拽一个 Video 组件过去:
对应的底层操作就是这样的:
addComponent({
id: 333,
name: 'Video',
props: {},
children: []
}, 222);在编辑器中把这个组件删除:
对应的操作就是 deleteComponent:
setTimeout(() => {
deleteComponent(333);
}, 3000);在右边属性编辑区修改组件的信息:
对应的就是 updateComponentProps:
(amis 用的 body 属性关联子组件,我们用的 children)
至于大纲和 json:
就是对这个 json 的展示:
所以说,从物料区拖组件到画布,删除组件、在属性编辑区修改组件属性,都是对这个 json 的修改。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 32bd1b33e74adb3832c839161aef415a0d4f3b20总结
我们分析了下低代码编辑器 amis,发现核心就是一个 json 的数据结构。
这个 json 就是一个通过 children 属性串联的组件对象树。
从物料区拖拽组件到画布区,就是在 json 的某一层级加了一个组件对象。
选中组件在右侧编辑属性,就是修改 json 里某个组件对象的属性。
大纲就是把这个 json 用树形展示。
然后我们写了下代码,用 allomet 实现了 split pane 布局,用 tailwind 来写样式,引入 zustand 来做全局 store。
在 store 中定义了 components 和对应的 add、update、delete 方法。
然后对应低代码编辑器里的操作,用这些方法实现了一下。
这个数据结构并不复杂,却是低代码编辑器的核心。
上节我们理清了低代码编辑器的实现原理,实现了核心数据结构 components 和 add、update、delete 方法。
并且把拖拽操作对应到了这些增删改方法上。
这节我们来实现下拖拽操作。
首先,我们把 json 渲染到中间的画布区:
现在的 json 里只有组件名,没有具体的组件:
我们写两个组件:
editor/materials/Container/index.tsx
import { PropsWithChildren } from 'react';
const Container = ({ children }: PropsWithChildren) => {
return (
<div
className='border-[1px] border-[#000] min-h-[100px] p-[20px]'
>{children}</div>
)
}
export default Container;因为布局放在 components 目录下,那物料组件就放 materials 目录下吧:
加了一个黑色的 border,设置了最小高度为 100px,padding 为 20px。
然后再加一个 Button 组件:
editor/materials/Button/index.tsx
import { Button as AntdButton } from 'antd';
import { ButtonType } from 'antd/es/button';
export interface ButtonProps {
type: ButtonType,
text: string;
}
const Button = ({type, text}: ButtonProps) => {
return (
<AntdButton type={type}>{text}</AntdButton>
)
}
export default Button;安装用到的 antd:
npm install --save-dev antd然后还要加一个 compnent 名字和 Component 实例的映射。
在 stores 下创建一个新的 Store
stores/component-config.tsx
import {create} from 'zustand';
import Container from '../materials/Container';
import Button from '../materials/Button';
export interface ComponentConfig {
name: string;
defaultProps: Record<string, any>,
component: any
}
interface State {
componentConfig: {[key: string]: ComponentConfig};
}
interface Action {
registerComponent: (name: string, componentConfig: ComponentConfig) => void
}
export const useComponentConfigStore = create<State & Action>((set) => ({
componentConfig: {
Container: {
name: 'Container',
defaultProps: {},
component: Container
},
Button: {
name: 'Button',
defaultProps: {
type: 'primary',
text: '按钮'
},
component: Button
},
},
registerComponent: (name, componentConfig) => set((state) => {
return {
...state,
componentConfig: {
...state.componentConfig,
[name]: componentConfig
}
}
})
}));声明 state 和 action 的类型。
state 就是 componentConfig 的映射。
key 是组件名,value 是组件配置(包括 component 组件实例、defaultProps 组件默认参数)。
action 就是往 componentConfig 里加配置。
componentConfig 现在有 Container、Button 两个组件。
有了组件的配置,接下来就可以渲染了:
在 EditArea/index.tsx 递归渲染 components
import React, { useEffect } from "react";
import { useComponentConfigStore } from "../../stores/component-config";
import { Component, useComponetsStore } from "../../stores/components"
export function EditArea() {
const { components, addComponent } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
useEffect(()=> {
addComponent({
id: 222,
name: 'Container',
props: {},
children: []
}, 1);
addComponent({
id: 333,
name: 'Button',
props: {
text: '无敌'
},
children: []
}, 222);
}, []);
function renderComponents(components: Component[]): React.ReactNode {
return components.map((component: Component) => {
const config = componentConfig?.[component.name]
if (!config?.component) {
return null;
}
return React.createElement(
config.component,
{
key: component.id,
...config.defaultProps,
...component.props,
},
renderComponents(component.children || [])
)
})
}
return <div className="h-[100%]">
<pre>
{JSON.stringify(components, null, 2)}
</pre>
{renderComponents(components)}
</div>
}components 是一个树形结构,我们 render 的时候也要递归渲染:
从组件配置中拿到 name 对应的组件实例,然后用 React.cloneElement 来创建组件。
props 是配置里的 defaultProps 用 component.props 覆盖后的结果。
React.cloneElement 的第三个参数是 children,递归调用 renderComponents 渲染就行。
这样,就把 components 组件树渲染了出来。
看下效果:
json 下面并没有渲染出组件来。
因为 Page 组件还没写。
写一下:
materials/Page/index.tsx
import { PropsWithChildren } from "react";
function Page({ children }: PropsWithChildren) {
return (
<div
className='p-[20px] h-[100%] box-border'
>
{children}
</div>
)
}
export default Page;在 componentConfig 里配置下:
Page: {
name: 'Page',
defaultProps: {},
component: Page
}把 json 注释掉:
看下渲染效果:
components 里的 Page、Container、Button 组件都渲染出来了。
用 react devtools 看下:
没啥问题。
这样,我们就把 components 的 json 渲染成了组件树。
把 addComponent 去掉,我们用拖拽的方式来添加组件:
拖拽用 react-dnd 来做。
安装 react-dnd 的包:
npm install react-dnd react-dnd-html5-backend在 main.tsx 里引入 DndProvider:
这个是 react-dnd 用来跨组件传递数据的
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
ReactDOM.createRoot(document.getElementById('root')!).render(
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
)然后在要拖拽的组件上添加 useDrag,在拖拽到的组件上添加 useDrop 就可以实现拖拽。
我们先写一下物料区:
components/Material/index.tsx
import { useMemo } from "react";
import { useComponentConfigStore } from "../../stores/component-config";
export function Material() {
const { componentConfig } = useComponentConfigStore();
const components = useMemo(() => {
return Object.values(componentConfig);
}, [componentConfig]);
return <div>{
components.map(item => {
return <div
className='
border-dashed
border-[1px]
border-[#000]
py-[8px] px-[10px]
m-[10px]
cursor-move
inline-block
bg-white
hover:bg-[#ccc]
'
>
{item.name}
</div>
})
}</div>
}读取 componentConfig 里注册的所有组件类型,渲染出来。
设置下 border、margin、padding。
看下效果:
我们要给每个 item 添加 useDrag 实现拖拽。
封装个组件:
components/MaterialItem/index.tsx
export interface MaterialItemProps {
name: string
}
export function MaterialItem(props: MaterialItemProps) {
const {
name
} = props;
return <div
className='
border-dashed
border-[1px]
border-[#000]
py-[8px] px-[10px]
m-[10px]
cursor-move
inline-block
bg-white
hover:bg-[#ccc]
'
>
{name}
</div>
}这样组件渲染的时候就可以用
components.map((item, index) => {
return <MaterialItem name={item.name} key={item.name + index}/>
})不影响页面渲染:
然后加一下 useDrag:
import { useEffect, useRef } from "react";
import { useDrag } from "react-dnd";
export interface MaterialItemProps {
name: string
}
export function MaterialItem(props: MaterialItemProps) {
const {
name
} = props;
const [_, drag] = useDrag({
type: name,
item: {
type: name
}
});
return <div
ref={drag}
className='
border-dashed
border-[1px]
border-[#000]
py-[8px] px-[10px]
m-[10px]
cursor-move
inline-block
bg-white
hover:bg-[#ccc]
'
>
{name}
</div>
}type 是当前 drag 的元素的标识,drop 的时候根据这个来决定是否 accept。
item 是传递的数据。
测试下:
现在就可以拖拽了。
只是还没处理 drop 的逻辑。
我们在 Page 组件加一下 useDrop 的处理逻辑:
import { message } from "antd";
import { PropsWithChildren } from "react";
import { useDrop } from "react-dnd";
function Page({ children }: PropsWithChildren) {
const [{ canDrop }, drop] = useDrop(() => ({
accept: ['Button', 'Container'],
drop: (item: { type: string}) => {
message.success(item.type)
},
collect: (monitor) => ({
canDrop: monitor.canDrop(),
}),
}));
return (
<div
ref={drop}
className='p-[20px] h-[100%] box-border'
style={{ border: canDrop ? '2px solid blue' : 'none' }}
>
{children}
</div>
)
}
export default Page;accept 指定接收的 type,这里接收 Button 和 Container 组件
drop 的时候显示下传过来的 item 数据。
canDrop 的话加一个 border 的高亮。
试一下:
可以看到,Container 和 Button 拖拽到 Page 组件的时候,会触发 drop 事件。
这需要把 id 传进来:
我们在 renderComponents 的时候传一下 component 的 id、name。
每个组件的参数都是这样,我们在 interface.ts 里定义下参数类型:
editor/interface.ts
import { PropsWithChildren } from "react";
export interface CommonComponentProps extends PropsWithChildren{
id: number;
name: string;
[key: string]: any
}然后调用下 addComponent:
import { useDrop } from "react-dnd";
import { CommonComponentProps } from "../../interface";
import { useComponetsStore } from "../../stores/components";
import { useComponentConfigStore } from "../../stores/component-config";
function Page({ id, name, children }: CommonComponentProps) {
const { addComponent } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [{ canDrop }, drop] = useDrop(() => ({
accept: ['Button', 'Container'],
drop: (item: { type: string}) => {
const props = componentConfig[item.type].defaultProps;
addComponent({
id: new Date().getTime(),
name: item.type,
props
}, id)
},
collect: (monitor) => ({
canDrop: monitor.canDrop(),
}),
}));
return (
<div
ref={drop}
className='p-[20px] h-[100%] box-border'
style={{ border: canDrop ? '2px solid blue' : 'none' }}
>
{children}
</div>
)
}
export default Page;测试下:
完美!
这样,拖拽编辑的第一步就完成了。
然后 Container 组件也是可以 drop 的。
我们加一下:
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
import { useDrop } from 'react-dnd';
import { CommonComponentProps } from '../../interface';
const Container = ({ id, children }: CommonComponentProps) => {
const { addComponent } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [{ canDrop }, drop] = useDrop(() => ({
accept: ['Button', 'Container'],
drop: (item: { type: string}) => {
const props = componentConfig[item.type].defaultProps;
addComponent({
id: new Date().getTime(),
name: item.type,
props
}, id)
},
collect: (monitor) => ({
canDrop: monitor.canDrop(),
}),
}));
return (
<div
ref={drop}
className={`min-h-[100px] p-[20px] ${ canDrop ? 'border-[2px] border-[blue]' : 'border-[1px] border-[#000]'}`}
>{children}</div>
)
}
export default Container;测试下:
可以拖拽组件到 Container 了,但是 Page 的 drop 也被触发了。
我们要加一下判断,处理过 drop 就不再处理。
const didDrop = monitor.didDrop()
if (didDrop) {
return;
}这样就好了:
没啥问题。
useDrop 代码重复了两次,我们封装一个自定义 hooks:
editor/hooks/useMaterialDrop.ts
import { useDrop } from "react-dnd";
import { useComponentConfigStore } from "../stores/component-config";
import { useComponetsStore } from "../stores/components";
export function useMaterailDrop(accept: string[], id: number) {
const { addComponent } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [{ canDrop }, drop] = useDrop(() => ({
accept,
drop: (item: { type: string}, monitor) => {
const didDrop = monitor.didDrop()
if (didDrop) {
return;
}
const props = componentConfig[item.type].defaultProps;
addComponent({
id: new Date().getTime(),
name: item.type,
props
}, id)
},
collect: (monitor) => ({
canDrop: monitor.canDrop(),
}),
}));
return { canDrop, drop }
}传入 accept 和 id 参数,返回 canDrop 和 drop。
在 Page 和 Container 组件用一下:
import { CommonComponentProps } from "../../interface";
import { useMaterailDrop } from "../../hooks/useMaterailDrop";
function Page({ id, name, children }: CommonComponentProps) {
const {canDrop, drop } = useMaterailDrop(['Button', 'Container'], id);
return (
<div
ref={drop}
className='p-[20px] h-[100%] box-border'
style={{ border: canDrop ? '2px solid blue' : 'none' }}
>
{children}
</div>
)
}
export default Page;import { useMaterailDrop } from '../../hooks/useMaterailDrop';
import { CommonComponentProps } from '../../interface';
const Container = ({ id, children }: CommonComponentProps) => {
const {canDrop, drop } = useMaterailDrop(['Button', 'Container'], id);
return (
<div
ref={drop}
className={`min-h-[100px] p-[20px] ${ canDrop ? 'border-[2px] border-[blue]' : 'border-[1px] border-[#000]'}`}
>{children}</div>
)
}
export default Container;这样代码好看多了。
然后我们先在 Setting 组件里展示下 json:
import { useComponetsStore } from "../../stores/components";
export function Setting() {
const { components } = useComponetsStore();
return <div>
<pre>
{JSON.stringify(components, null, 2)}
</pre>
</div>
}测试下:
可以看到,拖拽编辑的时候,json 和画布的内容会同步修改。
完美!
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 6f55fcbcc93bfec667975d808ac2d4c3f97fac05总结
这节我们实现了拖拽组件到画布,也就是拖拽编辑 json。
首先我们加了 Button 和 Container 组件,并创建了 componentConfig 的全局 store,用来保存组件配置。
然后实现了 renderComponents,它就是递归渲染 component,用到的组件配置从 componentConfig 取。
之后引入 react-dnd 实现了拖拽编辑,左侧的物料添加 useDrag,画布里的组件添加 useDrop,然后当 drop 的时候,在对应 id 下添加一个对应的类型的组件。
组件类型在 useDrag 的时候通过 item 传递,添加到的组件 id 在 drop 的那个组件里就有。
然后还要处理下 didDrop,保证只 drop 一次。
这样,我们就实现了拖拽编辑 json 的功能。
上节我们实现了 json 到组件树的渲染,以及拖拽改变 json,支持任意层级:
这节我们继续来实现编辑时的交互效果。
也就是这个:
鼠标 hover 到画布区的任意组件,都会有高亮效果:
选中组件的时候,会有框选效果:
这种效果怎么实现呢?
最容易想到的就是每个组件都做下处理,hover 或者 click 的时候展示编辑框。
但每个组件都加这段逻辑比较麻烦。
更好的方式是在画布区根组件统一监听 hover 和 click,根据触发事件的元素的 width、height、left、top,来显示编辑框。
类似我们之前实现的 OnBoarding 组件:
就是一个 div 来改变 width、height、left、top 实现的。
这里也类似。
我们实现下:
我们需要知道 hover 或者 click 的元素对应的 component 的 id。
在渲染的时候加一下这个:
import { Button as AntdButton } from 'antd';
import { CommonComponentProps } from '../../interface';
const Button = ({id, type, text}: CommonComponentProps) => {
return (
<AntdButton data-component-id={id} type={type}>{text}</AntdButton>
)
}
export default Button;试一下:
拖拽两个组件过来。
可以看到,id 加在了组件元素的 data-component-id 属性上。
然后在 EditArea 里处理下 hover
const [hoverComponentId, setHoverComponentId] = useState<number>();
const handleMouseOver: MouseEventHandler = (e) => {
const path = e.nativeEvent.composedPath();
for (let i = 0; i < path.length; i += 1) {
const ele = path[i] as HTMLElement;
const componentId = ele.dataset?.componentId;
if (componentId) {
setHoverComponentId(+componentId);
return;
}
}
}mouseover 的时候做下处理,找到元素的 data-component-id 设置为 hoverComponentId 的 state
加个 debugger
浏览器里打开 devtools,鼠标划到画布区:
可以看到 composedPath 是从触发事件的元素到 html 根元素的路径。
这是 event 对象的 api。
为啥不直接 e.composedPath 而是取 e.nativeEvent.composedPath 呢?
因为 react 里的 event 是合成事件,有的原生事件的属性它没有:
这时候就可以通过 e.nativeEvent 取它的原生事件:
然后我们在整个路径从底向上找,找到第一个有 data-component-id 的元素。
它就是当前 hover 的组件了。
还有这个 ele.dataset,它是一个 dom 的属性,包含所有 data-xx 的属性的值:
这样,在 hover 到不同 component 的时候,就能拿到对应的 componentId
我们渲染下这个 hoverComponentId:
没啥问题。
然后接下来就是拿到 component-id 对应的 dom 的 with、height、left、top,加一个框上去就好了。
我们创建个组件来写这个:
editor/components/HoverMask/index.tsx
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
interface HoverMaskProps {
containerClassName: string
componentId: number;
}
function HoverMask({ containerClassName, componentId }: HoverMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0
});
useEffect(() => {
updatePosition();
}, [componentId]);
function updatePosition() {
if (!componentId) return;
const container = document.querySelector(`.${containerClassName}`);
if (!container) return;
const node = document.querySelector(`[data-component-id="${componentId}"]`);
if (!node) return;
const { top, left, width, height } = node.getBoundingClientRect();
const { top: containerTop, left: containerLeft } = container.getBoundingClientRect();
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft + container.scrollTop,
width,
height
});
}
const el = useMemo(() => {
const el = document.createElement('div');
el.className = `wrapper`;
const container = document.querySelector(`.${containerClassName}`);
container!.appendChild(el);
return el;
}, []);
return createPortal((
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.1)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
), el)
}
export default HoverMask;从上到下来看:
首先,需要传入 containerClassName 和 componentId 参数:
componentId 就是 hover 的组件 id,而 containerClassName 就是画布区的根元素的 className。
比如上图,我们计算按钮和画布区顶部的距离,就需要按钮的 boundingClientRect 还有画布区的 boundingClientRect。
所以需要传入 containerClassName 和 componentId。
我们声明 left、top、width、height 的 state,调用 updatePosition 来计算这些位置。
计算方式如下:
获取两个元素的 boundingClientRect,计算 top、left 的差值,加上 scrollTop、scrollLeft。
因为 boundingClientRect 只是可视区也就是和视口的距离,要算绝对定位的位置的话要加上已滚动的距离。
然后创建一个 div 挂载在容器下,用于存放 portal:
具体的样式比较简单,就是设置下 top、left、width、height,然后设置下 border、background 就好了:
注意还要设置 pointer-event 为 none,不响应鼠标事件。
HoverMask 组件写完了,我们用一下:
{hoverComponentId && (
<HoverMask
containerClassName='edit-area'
componentId={hoverComponentId}
/>
)}看下效果:
高亮是对的,只是当鼠标离开画布区的时候还在高亮。
处理下 mouseleave 的时候:
onMouseLeave={() => {
setHoverComponentId(undefined);
}}这样就好了:
但只是高亮下意义不大,我们把组件名也显示下:
就是在加一个右上角 label 的位置计算,然后根据 id 找到对应 component 的 name 显示。
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { getComponentById, useComponetsStore } from '../../stores/components';
interface HoverMaskProps {
containerClassName: string
componentId: number;
}
function HoverMask({ containerClassName, componentId }: HoverMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
labelTop: 0,
labelLeft: 0,
});
const { components } = useComponetsStore();
useEffect(() => {
updatePosition();
}, [componentId]);
function updatePosition() {
if (!componentId) return;
const container = document.querySelector(`.${containerClassName}`);
if (!container) return;
const node = document.querySelector(`[data-component-id="${componentId}"]`);
if (!node) return;
const { top, left, width, height } = node.getBoundingClientRect();
const { top: containerTop, left: containerLeft } = container.getBoundingClientRect();
let labelTop = top - containerTop + container.scrollTop;
let labelLeft = left - containerLeft + width;
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft + container.scrollTop,
width,
height,
labelTop,
labelLeft,
});
}
const el = useMemo(() => {
const el = document.createElement('div');
el.className = `wrapper`;
const container = document.querySelector(`.${containerClassName}`);
container!.appendChild(el);
return el;
}, []);
const curComponent = useMemo(() => {
return getComponentById(componentId, components);
}, [componentId]);
return createPortal((
<>
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.05)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
<div
style={{
position: "absolute",
left: position.labelLeft,
top: position.labelTop,
fontSize: "14px",
zIndex: 13,
display: (!position.width || position.width < 10) ? "none" : "inline",
transform: 'translate(-100%, -100%)',
}}
>
<div
style={{
padding: '0 8px',
backgroundColor: 'blue',
borderRadius: 4,
color: '#fff',
cursor: "pointer",
whiteSpace: 'nowrap',
}}
>
{curComponent?.name}
</div>
</div>
</>
), el)
}
export default HoverMask;测试下:
这里的位置是这样算的:
labelTop 和高亮框一样,齐平。
labelLeft 是高亮框的 left,加上高亮框宽度。
然后 translate 回去:
如果不 tanslate 回去是这样的:
此外,还要处理下边界情况,Page 组件就没显示 label 因为定位到上面去了:
if (labelTop <= 0) {
labelTop -= -20;
}现在就能显示出来了:
其实还有个问题:
.wrapper 会创建多个。
这是因为 hoverComponentId 只要一变,就会卸载之前的 HoverMask 创建新的:
所以这段逻辑会执行多次,创建多个 .wrapper 元素:
这样性能不好。
我们改一下:
直接在 EditArea 里创建个元素用来挂载 portal,把 className 传入 HoverMask 组件。
return <div className="h-[100%] edit-area" onMouseOver={handleMouseOver} onMouseLeave={() => {
setHoverComponentId(undefined);
}} onClick={handleClick}>
{renderComponents(components)}
{hoverComponentId && (
<HoverMask
portalWrapperClassName='portal-wrapper'
containerClassName='edit-area'
componentId={hoverComponentId}
/>
)}
<div className="portal-wrapper"></div>
</div>HoverMask 直接把 portal 挂载到这个 className 的元素下就好了:
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { getComponentById, useComponetsStore } from '../../stores/components';
interface HoverMaskProps {
portalWrapperClassName: string;
containerClassName: string
componentId: number;
}
function HoverMask({ containerClassName, portalWrapperClassName, componentId }: HoverMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
labelTop: 0,
labelLeft: 0,
});
const { components } = useComponetsStore();
useEffect(() => {
updatePosition();
}, [componentId]);
function updatePosition() {
if (!componentId) return;
const container = document.querySelector(`.${containerClassName}`);
if (!container) return;
const node = document.querySelector(`[data-component-id="${componentId}"]`);
if (!node) return;
const { top, left, width, height } = node.getBoundingClientRect();
const { top: containerTop, left: containerLeft } = container.getBoundingClientRect();
let labelTop = top - containerTop + container.scrollTop;
let labelLeft = left - containerLeft + width;
if (labelTop <= 0) {
labelTop -= -20;
}
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft + container.scrollTop,
width,
height,
labelTop,
labelLeft,
});
}
const el = useMemo(() => {
return document.querySelector(`.${portalWrapperClassName}`)!
}, []);
const curComponent = useMemo(() => {
return getComponentById(componentId, components);
}, [componentId]);
return createPortal((
<>
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.05)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
<div
style={{
position: "absolute",
left: position.labelLeft,
top: position.labelTop,
fontSize: "14px",
zIndex: 13,
display: (!position.width || position.width < 10) ? "none" : "inline",
transform: 'translate(-100%, -100%)',
}}
>
<div
style={{
padding: '0 8px',
backgroundColor: 'blue',
borderRadius: 4,
color: '#fff',
cursor: "pointer",
whiteSpace: 'nowrap',
}}
>
{curComponent?.name}
</div>
</div>
</>
), el)
}
export default HoverMask;测试下:
现在就只会有一个 wrapper 元素了。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 8b0dacec372a39d4eb90090c0d0a694f7ed9485b总结
这节我们实现了下编辑的时候的交互,实现了 hover 的时候展示高亮框和组件名。
我们在每个组件渲染的时候加上了 data-component-id,然后在画布区根组件监听 mouseover 事件,通过触发事件的元素一层层往上找,找到 component-id。
然后 getBoudingClientRect 拿到这个元素的 width、height、left、top 等信息,和画布区根元素的位置做计算,算出高亮框的位置。
并在高亮框的右上角展示了组件名。
这样,编辑时高亮展示组件信息的功能就完成了。
上节实现了 hover 时展示高亮框和组件名的效果:
这节我们来实现 click 时展示编辑框,以及组件删除:
hover 时记录了 hoverComponentId:
click 时同样也要记录。
但是 hover 时不一样,click 选中的组件除了展示编辑框,还要在右侧属性区展示对应的组件属性:
所以我们要把它记录到全局 store 里。
我们加一下:
interface State {
components: Component[];
curComponentId?: number | null;
curComponent: Component | null;
}
interface Action {
addComponent: (component: Component, parentId?: number) => void;
deleteComponent: (componentId: number) => void;
updateComponentProps: (componentId: number, props: any) => void;
setCurComponentId: (componentId: number | null) => void;
}curComponentId: null,
curComponent: null,
setCurComponentId: (componentId) =>
set((state) => ({
curComponentId: componentId,
curComponent: getComponentById(componentId, state.components),
})),同样,click 事件也是绑定在画布区根组件 EditArea 上的:
import React, { MouseEventHandler, useEffect, useState } from "react";
import { useComponentConfigStore } from "../../stores/component-config";
import { Component, useComponetsStore } from "../../stores/components"
import HoverMask from "../HoverMask";
import SelectedMask from "../SelectedMask";
export function EditArea() {
const { components, curComponentId, setCurComponentId} = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
function renderComponents(components: Component[]): React.ReactNode {
return components.map((component: Component) => {
const config = componentConfig?.[component.name]
if (!config?.component) {
return null;
}
return React.createElement(
config.component,
{
key: component.id,
id: component.id,
name: component.name,
...config.defaultProps,
...component.props,
},
renderComponents(component.children || [])
)
})
}
const [hoverComponentId, setHoverComponentId] = useState<number>();
const handleMouseOver: MouseEventHandler = (e) => {
const path = e.nativeEvent.composedPath();
for (let i = 0; i < path.length; i += 1) {
const ele = path[i] as HTMLElement;
const componentId = ele.dataset?.componentId;
if (componentId) {
setHoverComponentId(+componentId);
return;
}
}
}
const handleClick: MouseEventHandler = (e) => {
const path = e.nativeEvent.composedPath();
for (let i = 0; i < path.length; i += 1) {
const ele = path[i] as HTMLElement;
const componentId = ele.dataset?.componentId;
if (componentId) {
setCurComponentId(+componentId);
return;
}
}
}
return <div className="h-[100%] edit-area" onMouseOver={handleMouseOver} onMouseLeave={() => {
setHoverComponentId(undefined);
}} onClick={handleClick}>
{renderComponents(components)}
{hoverComponentId && (
<HoverMask
portalWrapperClassName='portal-wrapper'
containerClassName='edit-area'
componentId={hoverComponentId}
/>
)}
{curComponentId && (
<SelectedMask
portalWrapperClassName='portal-wrapper'
containerClassName='edit-area'
componentId={curComponentId}
/>
)}
<div className="portal-wrapper"></div>
</div>
}点击事件触发时,找到元素对应的 component id,设置为 curComponentId。
然后渲染 curComponentId 对应的 SelectedMask。
实现下这个 SelectedMask 组件:
editor/components/SelectedMask/index.tsx
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { getComponentById, useComponetsStore } from '../../stores/components';
import { Popconfirm, Space } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
interface SelectedMaskProps {
portalWrapperClassName: string
containerClassName: string
componentId: number;
}
function SelectedMask({ containerClassName, portalWrapperClassName, componentId }: SelectedMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
labelTop: 0,
labelLeft: 0,
});
const { components, curComponentId } = useComponetsStore();
useEffect(() => {
updatePosition();
}, [componentId]);
function updatePosition() {
if (!componentId) return;
const container = document.querySelector(`.${containerClassName}`);
if (!container) return;
const node = document.querySelector(`[data-component-id="${componentId}"]`);
if (!node) return;
const { top, left, width, height } = node.getBoundingClientRect();
const { top: containerTop, left: containerLeft } = container.getBoundingClientRect();
let labelTop = top - containerTop + container.scrollTop;
let labelLeft = left - containerLeft + width;
if (labelTop <= 0) {
labelTop -= -20;
}
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft + container.scrollTop,
width,
height,
labelTop,
labelLeft,
});
}
const el = useMemo(() => {
return document.querySelector(`.${portalWrapperClassName}`)!
}, []);
const curComponent = useMemo(() => {
return getComponentById(componentId, components);
}, [componentId]);
function handleDelete() {
}
return createPortal((
<>
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.1)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
<div
style={{
position: "absolute",
left: position.labelLeft,
top: position.labelTop,
fontSize: "14px",
zIndex: 13,
display: (!position.width || position.width < 10) ? "none" : "inline",
transform: 'translate(-100%, -100%)',
}}
>
<Space>
<div
style={{
padding: '0 8px',
backgroundColor: 'blue',
borderRadius: 4,
color: '#fff',
cursor: "pointer",
whiteSpace: 'nowrap',
}}
>
{curComponent?.name}
</div>
{curComponentId !== 1 && (
<div style={{ padding: '0 8px', backgroundColor: 'blue' }}>
<Popconfirm
title="确认删除?"
okText={'确认'}
cancelText={'取消'}
onConfirm={handleDelete}
>
<DeleteOutlined style={{ color: '#fff' }}/>
</Popconfirm>
</div>
)}
</Space>
</div>
</>
), el)
}
export default SelectedMask;和 HoverMask 区别不大,主要这几点区别:
从 store 取出 curComponentId 来。
如果 id 不为 1,说明不是 Page 组件,就显示删除按钮。
点击的时候删除组件:
再就是编辑框的颜色稍微深一点:
测试下:
点击时显示了编辑框,并且点击删除能删除组件。
只是会和 HoverMask 重合。
我们处理下:
hoverComponentId 和 curComponentId 一样的时候,就不显示高亮框。
这样就好了。
amis 的编辑器还有这个功能:
组件会展示它所有的父组件,点击就会选中该父组件。
我们也实现下:
每个组件都有 component.parentId,用来找父组件也很简单,不断向上找,放到一个数组里就行。
然后用 DropDown 组件展示下拉列表:
点击 item 的时候切换 curComponentId。
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { getComponentById, useComponetsStore } from '../../stores/components';
import { Dropdown, Popconfirm, Space } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
interface SelectedMaskProps {
portalWrapperClassName: string
containerClassName: string
componentId: number;
}
function SelectedMask({ containerClassName, portalWrapperClassName, componentId }: SelectedMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
labelTop: 0,
labelLeft: 0,
});
const { components, curComponentId, curComponent, deleteComponent, setCurComponentId } = useComponetsStore();
useEffect(() => {
updatePosition();
}, [componentId]);
function updatePosition() {
if (!componentId) return;
const container = document.querySelector(`.${containerClassName}`);
if (!container) return;
const node = document.querySelector(`[data-component-id="${componentId}"]`);
if (!node) return;
const { top, left, width, height } = node.getBoundingClientRect();
const { top: containerTop, left: containerLeft } = container.getBoundingClientRect();
let labelTop = top - containerTop + container.scrollTop;
let labelLeft = left - containerLeft + width;
if (labelTop <= 0) {
labelTop -= -20;
}
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft + container.scrollTop,
width,
height,
labelTop,
labelLeft,
});
}
const el = useMemo(() => {
return document.querySelector(`.${portalWrapperClassName}`)!
}, []);
const curSelectedComponent = useMemo(() => {
return getComponentById(componentId, components);
}, [componentId]);
function handleDelete() {
deleteComponent(curComponentId!);
setCurComponentId(null);
}
const parentComponents = useMemo(() => {
const parentComponents = [];
let component = curComponent;
while (component?.parentId) {
component = getComponentById(component.parentId, components)!;
parentComponents.push(component);
}
return parentComponents;
}, [curComponent]);
return createPortal((
<>
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.1)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
<div
style={{
position: "absolute",
left: position.labelLeft,
top: position.labelTop,
fontSize: "14px",
zIndex: 13,
display: (!position.width || position.width < 10) ? "none" : "inline",
transform: 'translate(-100%, -100%)',
}}
>
<Space>
<Dropdown
menu={{
items: parentComponents.map(item => ({
key: item.id,
label: item.name,
})),
onClick: ({ key }) => {
setCurComponentId(+key);
}
}}
disabled={parentComponents.length === 0}
>
<div
style={{
padding: '0 8px',
backgroundColor: 'blue',
borderRadius: 4,
color: '#fff',
cursor: "pointer",
whiteSpace: 'nowrap',
}}
>
{curSelectedComponent?.name}
</div>
</Dropdown>
{curComponentId !== 1 && (
<div style={{ padding: '0 8px', backgroundColor: 'blue' }}>
<Popconfirm
title="确认删除?"
okText={'确认'}
cancelText={'取消'}
onConfirm={handleDelete}
>
<DeleteOutlined style={{ color: '#fff' }}/>
</Popconfirm>
</div>
)}
</Space>
</div>
</>
), el)
}
export default SelectedMask;试一下:
这样,选中父组件的功能就完成了。
但现在有个问题:
删除组件后会触发它父组件的 hover,但这时候高亮框的高度是没删除元素的高度,会多出一块。
还有,click 选中的组件再添加组件的时候编辑框高度不会变化:
这个问题也好解决,在 components 变化后调用下 updatePosition 就好了:
useEffect(() => {
updatePosition();
}, [components]);SelectedMask 和 HoverMask 都处理下。
这样就好了。
此外,amis 编辑器左边物料和选中时的编辑框都是展示的组件描述,而我们直接展示组件名:
这样不大好,我们改一下:
在 Component 类型加一下 desc:
ComponentConfig 也加一下:
addComponent 的时候从 config 取出组件的 desc:
然后展示的时候展示 desc 就好了:
左边的 MaterialItem 传入 desc:
显示的文案改成 desc:
HoverMask 和 SelectedMask 也显示 desc:
测试下:
没啥问题。
然后左边不需要展示页面组件,过滤下:
还有,使用者是可能调整窗口大小的,这时候编辑框没有重新计算位置:
做下处理:
useEffect(() => {
const resizeHandler = () => {
updatePosition();
}
window.addEventListener('resize', resizeHandler)
return () => {
window.removeEventListener('resize', resizeHandler)
}
}, []);这样就好了:
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard f8f0cd06dc5c08f6df2f5dcb5d5327c4bb11d94b总结
这节我们实现了点击时的编辑框。
首先在 components 的 store 里保存了 curComponentId。
然后在 EditArea 添加 click 事件,点击的时候拿到 data-component-id 设置到 curComponentId。
根据 curComponentId 渲染 SelectedMask。
SelctedMask 展示删除按钮,可以调用 deleteComponent 删除组件,展示父组件的列表,可以切换选中父组件。
渲染 SelectedMask 的时候要隐藏掉 HoverMask。
还要做 components 变化、window resize 的时候的 udpatePosition 处理。
此外,我们还把展示的 component.name 换成了 component.desc
这样,画布区的交互就完成了。
这节我们来做属性编辑的功能。
在 amis 中,选中不同组件会在右边展示对应的属性:
编辑属性,会修改 json 中的内容:
我们只要在选中组件的时候,在右边展示组件对应属性的表单就行了。
不同组件的属性是不同的,这部分明显是在 componentConfig 里配置。
export interface ComponentSetter {
name: string;
label: string;
type: string;
[key: string]: any;
}
export interface ComponentConfig {
name: string;
defaultProps: Record<string, any>,
desc: string;
setter?: ComponentSetter[]
component: any
}先给 Button 加一下:
用 setter 属性来保存属性表单的配置,这里有 type、text 两个属性,就是两个表单项。
{
name: 'type',
label: '按钮类型',
type: 'select',
options: [
{label: '主按钮', value: 'primary'},
{label: '次按钮', value: 'default'},
],
},
{
name: 'text',
label: '文本',
type: 'input',
}name 是字段名、label 是前面的文案,type 是表单类型。
select 类型的表单多一个 options 来配置选项。
在 Setting 组件里取出 curComponentId 对应的属性,渲染成表单就好了:
其实 Setting 部分不只是设置属性,还可以设置样式、绑定事件:
我们先预留出位置来:
components/Setting/index.tsx
import { Segmented } from 'antd';
import { useState } from 'react';
import { useComponetsStore } from '../../stores/components';
import { ComponentAttr } from './ComponentAttr';
import { ComponentEvent } from './ComponentEvent';
import { ComponentStyle } from './ComponentStyle';
export function Setting() {
const { curComponentId } = useComponetsStore();
const [key, setKey] = useState<string>('属性');
if (!curComponentId) return null;
return <div >
<Segmented value={key} onChange={setKey} block options={['属性', '样式', '事件']} />
<div>
{
key === '属性' && <ComponentAttr />
}
{
key === '样式' && <ComponentStyle />
}
{
key === '事件' && <ComponentEvent />
}
</div>
</div>
}components/Setting/ComponentAttr.tsx
export function ComponentAttr() {
return <div>ComponentAttr</div>
}components/Setting/ComponentStyle.tsx
export function ComponentStyle() {
return <div>ComponentStyle</div>
}components/Setting/ComponentEvent.tsx
export function ComponentEvent() {
return <div>ComponentEvent</div>
}如果 curComponentId 为 null,也就是没有选中的组件,就 return null。
用 antd 的 Segmentd 组件来做上面的 tab。
然后分别用 ComponentAttr、ComponentStyle、ComponentEvent 组件渲染组件的属性、样式、事件。
没啥问题。
然后来写 ComponentAttr 组件:
import { Form, Input, Select } from 'antd';
import { useEffect } from 'react';
import { ComponentConfig, ComponentSetter, useComponentConfigStore } from '../../stores/component-config';
import { useComponetsStore } from '../../stores/components';
export function ComponentAttr() {
const [form] = Form.useForm();
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
useEffect(() => {
const data = form.getFieldsValue();
form.setFieldsValue({...data, ...curComponent?.props});
}, [curComponent])
if (!curComponentId || !curComponent) return null;
function renderFormElememt(setting: ComponentSetter) {
const { type, options } = setting;
if (type === 'select') {
return <Select options={options} />
} else if (type === 'input') {
return <Input />
}
}
function valueChange(changeValues: ComponentConfig) {
if (curComponentId) {
updateComponentProps(curComponentId, changeValues);
}
}
return (
<Form
form={form}
onValuesChange={valueChange}
labelCol={{ span: 8 }}
wrapperCol={{ span: 14 }}
>
<Form.Item label="组件id">
<Input value={curComponent.id} disabled />
</Form.Item>
<Form.Item label="组件名称">
<Input value={curComponent.name} disabled />
</Form.Item>
<Form.Item label="组件描述">
<Input value={curComponent.desc} disabled/>
</Form.Item>
{
componentConfig[curComponent.name]?.setter?.map(setter => (
<Form.Item key={setter.name} name={setter.name} label={setter.label}>
{renderFormElememt(setter)}
</Form.Item>
))
}
</Form>
)
}首先,如果 curComponentId 为 null,也就是没有选中组件的时候,返回 null
当 curComponent 变化的时候,把 props 设置到表单用于回显数据:
当表单 value 变化的时候,同步到 store:
下面就是表单项目,分别渲染 id、name、desc 属性,还有组件对应的 setter:
id、name、desc 都不可修改,设置 disabled。
setter 要根据类型来渲染不同的表单组件,比如 Select、Input。
测试下:
可以看到,当切换到 Page、Container、Button 组件的时候,展示了对应属性的表单。
现在按钮类型、文本都是可以修改的,画布区会同步变化:
没啥问题。
当然,现在我们组件还不多,之后组件多了以后,表单项类型会更多。
到时候扩展这里就可以了:
扩展更多的 setter 类型,支持 radio、checkbox 等表单项。
还有,现在这里贴的比较紧,我们加个 padding:
好多了。
然后我们再来写下样式的编辑:
在 components 的 store 添加 styles 和更新 styles 的方法:
updateComponentStyles: (componentId: number, styles: CSSProperties) => void;updateComponentStyles: (componentId, styles) =>
set((state) => {
const component = getComponentById(componentId, state.components);
if (component) {
component.styles = {...component.styles, ...styles};
return {components: [...state.components]};
}
return {components: [...state.components]};
}) 在渲染组件的时候传进去:
给渲染的组件参数加一个 styles 参数:
把 styles 渲染出来:
Button 组件:
Container 组件:
Page 组件:
然后我们在 addComponent 的时候加上个 styles 试试:
生效了。
这样我们就把 styles 保存在了 json 里,并且渲染的时候设置到了组件。
然后做下 styles 的编辑就好了。
amis 的样式编辑上面是一些 css 的样式可以选择,下面还可以直接写 css:
而且每个组件配置的样式都不同:
这个也和组件 props 一样,需要在 componentConfig 配下表单项:
stylesSetter?: ComponentSetter[]stylesSetter: [
{
name: 'width',
label: '宽度',
type: 'inputNumber',
},
{
name: 'height',
label: '高度',
type: 'inputNumber',
}
],然后在 ComponentStyle 里面渲染下:
import { Form, Input, InputNumber, Select } from 'antd';
import { CSSProperties, useEffect } from 'react';
import { ComponentConfig, ComponentSetter, useComponentConfigStore } from '../../stores/component-config';
import { useComponetsStore } from '../../stores/components';
export function ComponentStyle() {
const [form] = Form.useForm();
const { curComponentId, curComponent, updateComponentStyles } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
useEffect(() => {
const data = form.getFieldsValue();
form.setFieldsValue({...data, ...curComponent?.styles});
}, [curComponent])
if (!curComponentId || !curComponent) return null;
function renderFormElememt(setting: ComponentSetter) {
const { type, options } = setting;
if (type === 'select') {
return <Select options={options} />
} else if (type === 'input') {
return <Input />
} else if (type === 'inputNumber') {
return <InputNumber />
}
}
function valueChange(changeValues: CSSProperties) {
if (curComponentId) {
updateComponentStyles(curComponentId, changeValues);
}
}
return (
<Form
form={form}
onValuesChange={valueChange}
labelCol={{ span: 8 }}
wrapperCol={{ span: 14 }}
>
{
componentConfig[curComponent.name]?.stylesSetter?.map(setter => (
<Form.Item key={setter.name} name={setter.name} label={setter.label}>
{renderFormElememt(setter)}
</Form.Item>
))
}
</Form>
)
}和 ComponentAttr 没啥区别,就是把更新方法换成 updateComponentStyles
测试下:
可以看到,样式修改生效了。
Button 组件支持的样式配置肯定不是 width、height,后面再完善就行。
我们把直接写 css 的方式也实现下:
或者用类似 tailwind 的原子化 className 的方式,让用户自己选择,添加 className 也行:
这样比写 css 上手成本低一些。
用 @monaco-editor/react 来做 css 编辑器,它自带了代码提示功能。
npm install --save @monaco-editor/react封装个组件:
components/Setting/CssEditor.tsx
import MonacoEditor, { OnMount, EditorProps } from '@monaco-editor/react'
import { editor } from 'monaco-editor'
import { useEffect, useRef } from 'react'
export interface EditorFile {
name: string
value: string
language: string
}
interface Props {
value: string
onChange?: EditorProps['onChange']
options?: editor.IStandaloneEditorConstructionOptions
}
export default function CssEditor(props: Props) {
const {
value,
onChange,
options
} = props;
const handleEditorMount: OnMount = (editor, monaco) => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyJ, () => {
editor.getAction('editor.action.formatDocument')?.run()
});
}
return <MonacoEditor
height={'100%'}
path='component.css'
language='css'
onMount={handleEditorMount}
onChange={onChange}
value={value}
options={
{
fontSize: 14,
scrollBeyondLastLine: false,
minimap: {
enabled: false,
},
scrollbar: {
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6,
},
...options
}
}
/>
}之前写 react playground 的时候用过 monoco editor。
这里配置差不多。
支持 cmd + J 快捷键来格式化。
然后在 ComponentStyle 组件里用一下:
<div className='h-[200px] border-[1px] border-[#ccc]'>
<CssEditor value={`.comp{\n\n}`}/>
</div>试一下:
然后做下自定义 css 到 store 的同步:
onChange 的时候打印下值:
触发有点频繁了,我们引入 lodash 做下 debounce:
npm install --save lodash-es
npm install --save-dev @types/lodash-es加个 500ms 的 debounce。
这样就好多了。
然后把它保存到 store:
store 里保存的是 对象,而现在拿到的是 css 字符串,需要 parse 一下。
用 style-to-object 这个包:
调用下:
const handleEditorChange = debounce((value) => {
setCss(value);
let css: Record<string, any> = {};
try {
const cssStr = value.replace(/\/\*.*\*\//, '') // 去掉注释 /** */
.replace(/(\.?[^{]+{)/, '') // 去掉 .comp {
.replace('}', '');// 去掉 }
styleToObject(cssStr, (name, value) => {
css[name.replace(/-\w/, (item) => item.toUpperCase().replace('-', ''))] = value;
});
console.log(css);
updateComponentStyles(curComponentId, css);
} catch(e) {}
}, 500);style-to-object 只支持 style 的 parse:
我们需要把注释、.comp { } 去掉
只保留中间部分。
然后 parse 完之后是 font-size、border-color 这种,转为驼峰之后更新到 store。
试一下:
可以看到,打印了 css parse 之后的对象并且更新到的 store。
中间的组件也应用了这个样式。
这时候上面的样式表单,下面直接写的 css 都能生效:
但有个问题:
删除这些 css 后,左边的样式不会消失。
因为我们更新 styles 的时候和已有的 style 做了合并:
所以在编辑器里删除 css,合并后依然保留着之前的样式。
我们支持下整个替换就好了:
component.styles = replace ? {...styles} : {...component.styles, ...styles};如果 replace 参数传了 true,就整个替换 styles。
然后用的时候指定 replace 为 true:
updateComponentStyles(curComponentId, {...form.getFieldsValue(), ...css}, true);测试下:
现在两部分样式都会生效。
删除下面编辑器的样式也生效:
现在还有个问题,切换选中的组件的时候,表单没清空:
reset 一下就好了:
form.resetFields();表单好了,下面的编辑器也重置下:
声明一个 css 的 state,curComponent 改变的时候设置 store 里的内容到 state。
然后 toCSSStr 方法就是拼接 css 字符串的。
要注意 with、height 要补 px,因为上面的表单的值保存的是数字。
const [css, setCss] = useState<string>(`.comp{\n\n}`);
useEffect(() => {
form.resetFields();
const data = form.getFieldsValue();
form.setFieldsValue({...data, ...curComponent?.styles});
setCss(toCSSStr(curComponent?.styles!))
}, [curComponent])
function toCSSStr(css: Record<string, any>) {
let str = `.comp {\n`;
for(let key in css) {
let value = css[key];
if(!value) {
continue;
}
if(['width', 'height'].includes(key) && !value.toString().endsWith('px')) {
value += 'px';
}
str += `\t${key}: ${value};\n`
}
str += `}`;
return str;
}测试下:
这样,当选中的组件切换的时候,样式的切换就完成了。
但还有一个问题:
当样式改变的时候,编辑框的大小不会跟着改变。
但我们设置了 components 变化会 updatePosition 了呀:
这是因为 components 变了,到渲染完成,然后再 getBoundingClientRect 拿到改变后的宽高是有一段时间的。
加个延迟就好了:
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 32a88a2f26100be09727cb6ba1c7c33d5f491523总结
这节我们实现了属性和样式的编辑。
在 componentConfig 里加了 setter、stylesSetter 来保存不同组件的属性、样式表单配置。
然后在 Setting 区域渲染对应的表单。
表单变化的时候,修改 components 里对应的 styles、props 信息,传入组件渲染。
样式编辑我们还支持直接写 css,用 @monaco-editor/react 做的编辑器,然后编辑完用 style-to-object 转为对象后保存到 store。
当然,现在 setter 的表单配置不够完善,当后面新加组件的时候,需要什么表单类型再扩展就行。
这节我们来做下大纲和预览的功能。
大纲就是树形展示组件结构:
顺便把源码也做一下:
预览则是展示编辑好的页面:
我们先来实现下左边的大纲和源码。
创建 components/MaterialWrapper/index.tsx
import { Segmented } from "antd";
import { useState } from "react";
import { Material } from "../Material";
import { Outline } from "../Outline";
import { Source } from "../Source";
export function MaterialWrapper() {
const [key, setKey] = useState<string>('物料');
return <div >
<Segmented value={key} onChange={setKey} block options={['物料', '大纲', '源码']} />
<div className='pt-[20px]'>
{
key === '物料' && <Material/>
}
{
key === '大纲' && <Outline/>
}
{
key === '源码' && <Source/>
}
</div>
</div>
}同样用 Segmented 组件来写 tab。
然后创建 Outline、Source 组件:
components/Outline/index.tsx
export function Outline() {
return <div>Outline</div>
}components/Source/index.tsx
export function Source() {
return <div>Source</div>
}把 editor/index.tsx 里的 Materail 换成 MaterialWrapper
试一下:
这样,tab 切换就完成了,并且之前的物料拖拽依然是正常的。
然后实现下大纲和源码。
大纲就是树形展示组件树:
用 antd 的 Tree 组件就行
import { Tree } from "antd";
import { useComponetsStore } from "../../stores/components";
export function Outline() {
const { components, setCurComponentId } = useComponetsStore();
return <Tree
fieldNames={{ title: 'desc', key: 'id' }}
treeData={components as any}
showLine
defaultExpandAll
onSelect={([selectedKey]) => {
setCurComponentId(selectedKey as number);
}}
/>
}title 是指定用哪个属性作为标题,key 是指定哪个属性作为 key。
选中的时候切换 curComponentId。
看下效果:
用 Tree 组件很简单就完成了。
然后是 Source,这个就更简单了,直接用 monaco editor 展示 json:
import MonacoEditor, { OnMount } from '@monaco-editor/react'
import { useComponetsStore } from '../../stores/components';
export function Source() {
const {components} = useComponetsStore();
const handleEditorMount: OnMount = (editor, monaco) => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyJ, () => {
editor.getAction('editor.action.formatDocument')?.run()
});
}
return <MonacoEditor
height={'100%'}
path='components.json'
language='json'
onMount={handleEditorMount}
value={JSON.stringify(components, null, 2)}
options={
{
fontSize: 14,
scrollBeyondLastLine: false,
minimap: {
enabled: false,
},
scrollbar: {
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6,
}
}
}
/>
}把 components 用 JSON.stringify 格式化后展示就行。
高度有点小,在 MaterialWrapper 设置下 height:
h-[calc(100vh-60px-30px-20px)]就是 100 的视口高度减去 header、tab 还有 padding 之后剩下的。
然后我们再实现下预览功能:
有同学说,预览和画布区不一样也是遍历 json 递归渲染组件么?
对,但是渲染的组件不同。
就拿日期组件来说:
编辑的时候不响应点击事件,预览的时候才有反应。
这是因为编辑的组件做了处理:
每个组件都要区分编辑和预览两种状态,甚至渲染的内容都不同。
所以,我们最好是编辑和预览状态的组件分开写:
改下 ComponentConfig,添加 dev、prod 属性。
然后我们给 Page、Button、Container 组件都添加两种状态的:
dev.tsx 就是之前的 index.tsx
我们只看 prod.tsx
Button 组件:
import { Button as AntdButton } from 'antd';
import { CommonComponentProps } from '../../interface';
const Button = ({id, type, text, styles}: CommonComponentProps) => {
return (
<AntdButton type={type} style={styles}>{text}</AntdButton>
)
}
export default Button;和 dev 状态差不多,只不过不用带 data-component-id 了
Container 组件:
import { CommonComponentProps } from '../../interface';
const Container = ({ id, children, styles }: CommonComponentProps) => {
return (
<div
style={styles}
className={`p-[20px]`}
>{children}</div>
)
}
export default Container;不用带 border,也不用处理 drop 事件。
Page 组件:
import { CommonComponentProps } from "../../interface";
function Page({ id, name, children, styles }: CommonComponentProps) {
return (
<div
className='p-[20px]'
style={{ ...styles }}
>
{children}
</div>
)
}
export default Page;不用带 h-[100%] 了,这个只是编辑的时候需要。
然后在 ComponentConfig 里注册下:
import {create} from 'zustand';
import ContainerDev from '../materials/Container/dev';
import ContainerProd from '../materials/Container/prod';
import ButtonDev from '../materials/Button/dev';
import ButtonProd from '../materials/Button/prod';
import PageDev from '../materials/Page/dev';
import PageProd from '../materials/Page/prod';
export interface ComponentSetter {
name: string;
label: string;
type: string;
[key: string]: any;
}
export interface ComponentConfig {
name: string;
defaultProps: Record<string, any>,
desc: string;
setter?: ComponentSetter[],
stylesSetter?: ComponentSetter[]
dev: any;
prod: any;
}
interface State {
componentConfig: {[key: string]: ComponentConfig};
}
interface Action {
registerComponent: (name: string, componentConfig: ComponentConfig) => void
}
export const useComponentConfigStore = create<State & Action>((set) => ({
componentConfig: {
Container: {
name: 'Container',
defaultProps: {},
desc: '容器',
dev: ContainerDev,
prod: ContainerProd
},
Button: {
name: 'Button',
defaultProps: {
type: 'primary',
text: '按钮'
},
setter: [
{
name: 'type',
label: '按钮类型',
type: 'select',
options: [
{label: '主按钮', value: 'primary'},
{label: '次按钮', value: 'default'},
],
},
{
name: 'text',
label: '文本',
type: 'input',
},
],
stylesSetter: [
{
name: 'width',
label: '宽度',
type: 'inputNumber',
},
{
name: 'height',
label: '高度',
type: 'inputNumber',
}
],
desc: '按钮',
dev: ButtonDev,
prod: ButtonProd
},
Page: {
name: 'Page',
defaultProps: {},
desc: '页面',
dev: PageDev,
prod: PageProd
}
},
registerComponent: (name, componentConfig) => set((state) => {
return {
...state,
componentConfig: {
...state.componentConfig,
[name]: componentConfig
}
}
})
}));然后 EditArea 里面渲染也改一下:
先看下效果:
功能正常。
然后加一个 Preview 组件:
components/Prview/index.tsx
import React from "react";
import { useComponentConfigStore } from "../../stores/component-config";
import { Component, useComponetsStore } from "../../stores/components"
export function Preview() {
const { components } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
function renderComponents(components: Component[]): React.ReactNode {
return components.map((component: Component) => {
const config = componentConfig?.[component.name]
if (!config?.prod) {
return null;
}
return React.createElement(
config.prod,
{
key: component.id,
id: component.id,
name: component.name,
styles: component.styles,
...config.defaultProps,
...component.props,
},
renderComponents(component.children || [])
)
})
}
return <div>
{renderComponents(components)}
</div>
}这个组件比 EditArea 简单,只要把 json 递归渲染成 prod 的组件就行。
然后在 store 添加一个 mode 的 state 用来切换编辑、预览状态:
mode: 'edit' | 'preview';setMode: (mode: State['mode']) => void;mode: 'edit',
setMode: (mode) => set({mode}),然后渲染的时候用 mode 区分下:
import { Allotment } from "allotment";
import 'allotment/dist/style.css';
import { Header } from "./components/Header";
import { EditArea } from "./components/EditArea";
import { Setting } from "./components/Setting";
import { MaterialWrapper } from "./components/MaterialWrapper";
import { useComponetsStore } from "./stores/components";
import { Preview } from "./components/Preivew";
export default function ReactPlayground() {
const { mode } = useComponetsStore();
return <div className='h-[100vh] flex flex-col'>
<div className='h-[60px] flex items-center border-b-[1px] border-[#000]'>
<Header />
</div>
{
mode === 'edit'
? <Allotment>
<Allotment.Pane preferredSize={240} maxSize={300} minSize={200}>
<MaterialWrapper />
</Allotment.Pane>
<Allotment.Pane>
<EditArea />
</Allotment.Pane>
<Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
<Setting />
</Allotment.Pane>
</Allotment>
: <Preview/>
}
</div>
}根据 mode 来渲染不同的足迹啊。
然后在 Header 加个预览按钮来切换 mode
import { Button, Space } from 'antd';
import { useComponetsStore } from '../../stores/components';
export function Header() {
const { mode, setMode, setCurComponentId } = useComponetsStore();
return (
<div className='w-[100%] h-[100%]'>
<div className='h-[50px] flex justify-between items-center px-[20px]'>
<div>低代码编辑器</div>
<Space>
{mode === 'edit' && (
<Button
onClick={() => {
setMode('preview');
setCurComponentId(null);
}}
type='primary'
>
预览
</Button>
)}
{mode === 'preview' && (
<Button
onClick={() => { setMode('edit') }}
type='primary'
>
退出预览
</Button>
)}
</Space>
</div>
</div>
)
}加个预览、退出预览按钮,点击切换 mode。
当 mode 切换为 edit 时,还要把 curComponentId 置空
测试下:
这样,预览功能就完成了。
当然,现在组件比较少,后面多加一些组件就好了。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 1db99bed7d588ac86fd0bdc006fad433f031cd31总结
这节我们实现了源码、大纲、预览的功能。
源码和大纲比较简单,就是 json 的不同形式的展示,分别用 @monaco-editor/react 和 Tree 组件来做。
预览功能也是递归渲染 json 为组件树,但是组件不一样,预览和编辑状态的组件要分开写。
我们在 store 加了一个 mode 的状态,切换 mode 来切换渲染的内容。
这样,从编辑到预览的流程就打通了。
这节我们来实现下事件绑定。
现在看下 amis 里事件绑定的流程:
选中组件,在事件面板会列出可以绑定的事件。
选中某个事件之后,可以添加动作:
你可以添加自定义执行的 JS 代码。
或者执行一些内置的动作,比如跳转链接。
还可以调用别的组件的方法,比如修改某个组件的显示隐藏:
这节我们就实现下。
首先,不同组件可绑定的事件是不同的:
这明显也是需要配置的。
我们在 componentConfig 里加上这个配置:
export interface ComponentEvent {
name: string
label: string
}
export interface ComponentConfig {
name: string;
defaultProps: Record<string, any>,
desc: string;
setter?: ComponentSetter[];
stylesSetter?: ComponentSetter[];
events?: ComponentEvent[];
dev: any;
prod: any;
}然后给 Button 组件配置一下:
events: [
{
name: 'onClick',
label: '点击事件',
},
{
name: 'onDoubleClick',
label: '双击事件'
},
],改下 Setting/ComponentEvent.tsx 组件,把事件渲染出来:
import { Collapse, Input, Select, CollapseProps} from 'antd';
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
if (!curComponent) return null;
const items: CollapseProps['items'] = (componentConfig[curComponent.name].events || []).map(event => {
return {
key: event.name,
label: event.label,
children: <div>
<div className='flex items-center'>
<div>动作:</div>
<Select
className='w-[160px]'
options={[
{ label: '显示提示', value: 'showMessage' },
{ label: '跳转链接', value: 'goToLink' },
]}
value={curComponent?.props?.[event.name]?.type}
/>
</div>
</div>
}
})
return <div className='px-[10px]'>
<Collapse className='mb-[10px]' items={items}/>
</div>
}根据 curComponent 从 componentConfig 取出对应组件的 events 配置。
用 antd 的 Collapse 组件渲染。
这样选中按钮组件的时候,就会渲染出它可以绑定的事件。
内置了两个动作:显示提示、跳转链接
当选择某个动作的时候,我们把它保存到 store 里。
比如 onClick 选择了 gotoLink 的动作,那就会在 component.props 上添加这样一个属性:
onClick: {
type: 'gotoLink'
}onChange={(value) => { selectAction(event.name, value) }}function selectAction(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, { [eventName]: { type: value, } })
}然后当切换到不同 action 的时候,显示对应的表单:
import { Collapse, Input, Select, CollapseProps} from 'antd';
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
if (!curComponent) return null;
function selectAction(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, { [eventName]: { type: value, } })
}
function urlChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
url: value
}
})
}
const items: CollapseProps['items'] = (componentConfig[curComponent.name].events || []).map(event => {
return {
key: event.name,
label: event.label,
children: <div>
<div className='flex items-center'>
<div>动作:</div>
<Select
className='w-[160px]'
options={[
{ label: '显示提示', value: 'showMessage' },
{ label: '跳转链接', value: 'goToLink' },
]}
onChange={(value) => { selectAction(event.name, value) }}
value={curComponent?.props?.[event.name]?.type}
/>
</div>
{
curComponent?.props?.[event.name]?.type === 'goToLink' && (
<div className='mt-[10px]'>
<div className='flex items-center gap-[10px]'>
<div>链接</div>
<div>
<Input
onChange={(e) => { urlChange(event.name, e.target.value) }}
value={curComponent?.props?.[event.name]?.url}
/>
</div>
</div>
</div>
)
}
</div>
}
})
return <div className='px-[10px]'>
<Collapse className='mb-[10px]' items={items}/>
</div>
}测试下:
当切换动作为跳转链接的时候,就会显示 url 的输入框。
输入 url 后,可以在 json 里看到这个信息:
那渲染的时候根据这个绑定 click 事件就好了。
改下 Preview 组件:
根据 componentConfig 里的事件类型给组件绑定事件。
如果有 components.props 里如果有 goToLink 的配置,就跳转链接。
import React from "react";
import { useComponentConfigStore } from "../../stores/component-config";
import { Component, useComponetsStore } from "../../stores/components"
export function Preview() {
const { components } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
function handleEvent(component: Component) {
const props: Record<string, any> = {};
componentConfig[component.name].events?.forEach((event) => {
const eventConfig = component.props[event.name];
if (eventConfig) {
const { type } = eventConfig;
props[event.name] = () => {
if (type === 'goToLink' && eventConfig.url) {
window.location.href = eventConfig.url;
}
}
}
})
return props;
}
function renderComponents(components: Component[]): React.ReactNode {
return components.map((component: Component) => {
const config = componentConfig?.[component.name]
if (!config?.prod) {
return null;
}
return React.createElement(
config.prod,
{
key: component.id,
id: component.id,
name: component.name,
styles: component.styles,
...config.defaultProps,
...component.props,
...handleEvent(component)
},
renderComponents(component.children || [])
)
})
}
return <div>
{renderComponents(components)}
</div>
}然后组件里接收这个参数:
测试下:
这样,我们第一个动作就完成了。
对比下 amis 里的实现:
没跳转是因为 amis 在预览模式下禁止了跳转:
虽然交互有点区别,但流程是一样的。
看下 amis 的 json:
也是把动作信息记录在 json 里,渲染的时候用这些来绑定事件。
动作后面会越来越多,所以最好抽成组件:
新建 Setting/actions/GoToLink.tsx
import { Input } from "antd"
import { ComponentEvent } from "../../../stores/component-config";
import { useComponetsStore } from "../../../stores/components";
export function GoToLink(props: { event: ComponentEvent }) {
const { event } = props;
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
function urlChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
url: value
}
})
}
return <div className='mt-[10px]'>
<div className='flex items-center gap-[10px]'>
<div>链接</div>
<div>
<Input
onChange={(e) => { urlChange(event.name, e.target.value) }}
value={curComponent?.props?.[event.name]?.url}
/>
</div>
</div>
</div>
}把跳转链接的表单抽离到这里:
然后我们再实现一个动作:
Setting/actions/ShowMessage.tsx
import { Input, Select } from "antd"
import { ComponentEvent } from "../../../stores/component-config";
import { useComponetsStore } from "../../../stores/components";
export function ShowMessage(props: { event: ComponentEvent }) {
const { event } = props;
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
function messageTypeChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
config: {
...curComponent?.props?.[eventName]?.config,
type: value,
},
}
})
}
function messageTextChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
config: {
...curComponent?.props?.[eventName]?.config,
text: value,
},
},
})
}
return <div className='mt-[10px]'>
<div className='flex items-center gap-[10px]'>
<div>类型:</div>
<div>
<Select
style={{ width: 160 }}
options={[
{ label: '成功', value: 'success' },
{ label: '失败', value: 'error' },
]}
onChange={(value) => { messageTypeChange(event.name, value) }}
value={curComponent?.props?.[event.name]?.config?.type}
/>
</div>
</div>
<div className='flex items-center gap-[10px] mt-[10px]'>
<div>文本:</div>
<div>
<Input
onChange={(e) => { messageTextChange(event.name, e.target.value) }}
value={curComponent?.props?.[event.name]?.config?.text}
/>
</div>
</div>
</div>
}和 GoToLink 差不多,只不过现在多了一个 Select 表单。
用一下:
{
curComponent?.props?.[event.name]?.type === 'showMessage' && <ShowMessage event={event}/>
}渲染的时候做下处理:
props[event.name] = () => {
if (type === 'goToLink' && eventConfig.url) {
window.location.href = eventConfig.url;
} else if (type === 'showMessage' && eventConfig.config) {
if (eventConfig.config.type === 'success') {
message.success(eventConfig.config.text);
} else if (eventConfig.config.type === 'error') {
message.error(eventConfig.config.text);
}
}
}试一下效果:
这样我们就实现了 showMessage 的动作:
试下 amis 里的:
一样。
当然,amis 里是支持绑定多个动作的:
它的 actions 是个数组:
我们目前只支持绑定一个 action。
这个也很简单,就是把存储结构改为数组,然后界面支持添加多个动作,大家可以自己完善。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 4fd81d180f8369efb4142876944b0c70a6f4cd6c总结
这节我们实现了事件绑定。
我们先实现了内置动作的方式。
在 comonentConfig 里配置组件可以绑定的事件,然后在 Setting 区事件面板里展示。
可以选择绑定的动作,比如跳转链接,显示提示,输入一些参数之后,就会保存到 json 里。
然后渲染 Preview 的时候根据这些信息来绑定事件。
我们对比了下和 amis 的区别,内置动作这些的实现一样的。
当然,事件绑定还有别的方式,下节我们继续完善。
上节我们实现了事件绑定,并内置了两个动作:
我们没用弹窗展示动作:
这样当动作多了就不好展示了。
我们改一下:
新建 Setting/ActionModal.tsx
import { Modal, Segmented } from "antd";
import { useState } from "react";
import { GoToLink } from "./actions/GoToLink";
import { ComponentEvent } from "../../stores/component-config";
import { ShowMessage } from "./actions/ShowMessage";
interface ActionModalProps {
visible: boolean
eventConfig: ComponentEvent
handleOk: () => void
handleCancel: () => void
}
export function ActionModal(props: ActionModalProps) {
const {
visible,
handleOk,
eventConfig,
handleCancel
} = props;
const [key, setKey] = useState<string>('访问链接');
return <Modal
title="事件动作配置"
width={800}
open={visible}
okText="添加"
cancelText="取消"
onOk={handleOk}
onCancel={handleCancel}
>
<div className="h-[500px]">
<Segmented value={key} onChange={setKey} block options={['访问链接', '消息提示', '自定义 JS']} />
{
key === '访问链接' && <GoToLink event={eventConfig}/>
}
{
key === '消息提示' && <ShowMessage event={eventConfig}/>
}
</div>
</Modal>
}就是展示所有的动作,当选择某个动作,输入内容后,修改对应的 event 配置。
在 ComponentEvent 里调用下:
加一个 state 来控制弹窗打开关闭。
再加一个 state 来记录当前的 event 配置,当点击 label 的添加动作按钮的时候,打开弹窗,记录当前 event
import { Collapse, Input, Select, CollapseProps, Button} from 'antd';
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
import type { ComponentEvent } from '../../stores/component-config';
import { ActionModal } from './ActionModal';
import { useState } from 'react';
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
if (!curComponent) return null;
const items: CollapseProps['items'] = (componentConfig[curComponent.name].events || []).map(event => {
return {
key: event.name,
label: <div className='flex justify-between leading-[30px]'>
{event.label}
<Button type="primary" onClick={() => {
setCurEvent(event);
setActionModalOpen(true);
}}>添加动作</Button>
</div>,
children: <div>
</div>
}
})
return <div className='px-[10px]'>
<Collapse className='mb-[10px]' items={items}/>
<ActionModal visible={actionModalOpen} eventConfig={curEvent!} handleOk={() => {
setActionModalOpen(false)
}} handleCancel={() => {
setActionModalOpen(false)
}}/>
</div>
}
试一下:
展示出来了,就是有点小。
我们把表单改大一点:
import { Input } from "antd"
import { ComponentEvent } from "../../../stores/component-config";
import { useComponetsStore } from "../../../stores/components";
import TextArea from "antd/es/input/TextArea";
export function GoToLink(props: { event: ComponentEvent }) {
const { event } = props;
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
function urlChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
url: value
}
})
}
return <div className='mt-[40px]'>
<div className='flex items-center gap-[10px]'>
<div>跳转链接</div>
<div>
<TextArea
style={{height: 200, width: 500, border: '1px solid #000'}}
onChange={(e) => { urlChange(event.name, e.target.value) }}
value={curComponent?.props?.[event.name]?.url}
/>
</div>
</div>
</div>
}import { Input, Select } from "antd"
import { ComponentEvent } from "../../../stores/component-config";
import { useComponetsStore } from "../../../stores/components";
export function ShowMessage(props: { event: ComponentEvent }) {
const { event } = props;
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
function messageTypeChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
config: {
...curComponent?.props?.[eventName]?.config,
type: value,
},
}
})
}
function messageTextChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
config: {
...curComponent?.props?.[eventName]?.config,
text: value,
},
},
})
}
return <div className='mt-[30px]'>
<div className='flex items-center gap-[20px]'>
<div>类型:</div>
<div>
<Select
style={{ width: 500, height: 50 }}
options={[
{ label: '成功', value: 'success' },
{ label: '失败', value: 'error' },
]}
onChange={(value) => { messageTypeChange(event.name, value) }}
value={curComponent?.props?.[event.name]?.config?.type}
/>
</div>
</div>
<div className='flex items-center gap-[20px] mt-[50px]'>
<div>文本:</div>
<div>
<Input
style={{ width: 500, height: 50 }}
onChange={(e) => { messageTextChange(event.name, e.target.value) }}
value={curComponent?.props?.[event.name]?.config?.text}
/>
</div>
</div>
</div>
}看下效果:
好多了。
之前我们是在 action 组件里直接修改 json,
现在改为通过 onChange 暴露出来,然后后面在点添加按钮的时候再改 json:
import { useState } from "react";
import { useComponetsStore } from "../../../stores/components";
import TextArea from "antd/es/input/TextArea";
export interface GoToLinkConfig {
type: 'goToLink',
url: string
}
export interface GoToLinkProps {
defaultValue?: string
onChange?: (config: GoToLinkConfig) => void
}
export function GoToLink(props: GoToLinkProps) {
const { defaultValue, onChange } = props;
const { curComponentId } = useComponetsStore();
const [value, setValue] = useState(defaultValue);
function urlChange(value: string) {
if (!curComponentId) return;
setValue(value);
onChange?.({
type: 'goToLink',
url: value
});
}
return <div className='mt-[40px]'>
<div className='flex items-center gap-[10px]'>
<div>跳转链接</div>
<div>
<TextArea
style={{height: 200, width: 500, border: '1px solid #000'}}
onChange={(e) => { urlChange(e.target.value) }}
value={value || ''}
/>
</div>
</div>
</div>
}现在不用传入 event 配置了,传入回显的 value 就行。
ShowMessage 组件也是这样改:
import { Input, Select } from "antd"
import { useComponetsStore } from "../../../stores/components";
import { useState } from "react";
export interface ShowMessageConfig {
type: 'showMessage',
config: {
type: 'success' | 'error'
text: string
}
}
export interface ShowMessageProps {
value?: ShowMessageConfig['config']
onChange?: (config: ShowMessageConfig) => void
}
export function ShowMessage(props: ShowMessageProps) {
const { value, onChange } = props;
const { curComponentId } = useComponetsStore();
const [type, setType] = useState<'success' | 'error'>(value?.type || 'success');
const [text, setText] = useState<string>(value?.text || '');
function messageTypeChange(value: 'success' | 'error') {
if (!curComponentId) return;
setType(value);
onChange?.({
type: 'showMessage',
config: {
type: value,
text
}
})
}
function messageTextChange(value: string) {
if (!curComponentId) return;
setText(value);
onChange?.({
type: 'showMessage',
config: {
type,
text: value
}
})
}
return <div className='mt-[30px]'>
<div className='flex items-center gap-[20px]'>
<div>类型:</div>
<div>
<Select
style={{ width: 500, height: 50 }}
options={[
{ label: '成功', value: 'success' },
{ label: '失败', value: 'error' },
]}
onChange={(value) => { messageTypeChange(value) }}
value={type}
/>
</div>
</div>
<div className='flex items-center gap-[20px] mt-[50px]'>
<div>文本:</div>
<div>
<Input
style={{ width: 500, height: 50 }}
onChange={(e) => { messageTextChange(e.target.value) }}
value={text}
/>
</div>
</div>
</div>
}试一下:
{
key === '访问链接' && <GoToLink onChange={(config) => {
console.log(config);
}}/>
}
{
key === '消息提示' && <ShowMessage onChange={(config) => {
console.log(config);
}}/>
}现在选择某个动作,填入配置的时候,在 ActionModal 里就能拿到。
那接下来只要在 handleOk 里传出去,然后父组件里加到 store 就可以了。
import { Modal, Segmented } from "antd";
import { useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ComponentEvent } from "../../stores/component-config";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
interface ActionModalProps {
visible: boolean
handleOk: (config?: GoToLinkConfig | ShowMessageConfig) => void
handleCancel: () => void
}
export function ActionModal(props: ActionModalProps) {
const {
visible,
handleOk,
handleCancel
} = props;
const [key, setKey] = useState<string>('访问链接');
const [curConfig, setCurConfig] = useState<GoToLinkConfig | ShowMessageConfig>();
return <Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}
>
<div className="h-[500px]">
<Segmented value={key} onChange={setKey} block options={['访问链接', '消息提示', '自定义 JS']} />
{
key === '访问链接' && <GoToLink onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '消息提示' && <ShowMessage onChange={(config) => {
setCurConfig(config);
}}/>
}
</div>
</Modal>
}在父组件里添加到 store 里:
import { Collapse, Input, Select, CollapseProps, Button} from 'antd';
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
import type { ComponentEvent } from '../../stores/component-config';
import { ActionModal } from './ActionModal';
import { useState } from 'react';
import { GoToLinkConfig } from './actions/GoToLink';
import { ShowMessageConfig } from './actions/ShowMessage';
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
if (!curComponent) return null;
const items: CollapseProps['items'] = (componentConfig[curComponent.name].events || []).map(event => {
return {
key: event.name,
label: <div className='flex justify-between leading-[30px]'>
{event.label}
<Button type="primary" onClick={() => {
setCurEvent(event);
setActionModalOpen(true);
}}>添加动作</Button>
</div>,
children: <div>
</div>
}
})
function handleModalOk(config?: GoToLinkConfig | ShowMessageConfig) {
if(!config || !curEvent || !curComponent) {
return ;
}
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config
]
}
})
setActionModalOpen(false)
}
return <div className='px-[10px]'>
<Collapse className='mb-[10px]' items={items}/>
<ActionModal visible={actionModalOpen} handleOk={handleModalOk} handleCancel={() => {
setActionModalOpen(false)
}}/>
</div>
}试一下:
现在的 json 结构就支持多个动作了:
和 amis 的一样:
然后我们也做下这个列表展示:
children: <div>
{
(curComponent.props[event.name]?.actions || []).map((item: GoToLinkConfig | ShowMessageConfig) => {
return <div>
{
item.type === 'goToLink' ? <div className='border border-[#aaa] m-[10px] p-[10px]'>
<div className='text-[blue]'>跳转链接</div>
<div>{item.url}</div>
</div> : null
}
{
item.type === 'showMessage' ? <div className='border border-[#aaa] m-[10px] p-[10px]'>
<div className='text-[blue]'>消息弹窗</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
</div> : null
}
</div>
})
}
</div>列表展示没问题。
只是每次都会触发展开收起。
我们加一个 defaultActiveKey 让所有的都展开:
defaultActiveKey={componentConfig[curComponent.name].events?.map(item =>item.name)}然后禁止点击事件冒泡,这样点击按钮就不会收起 Collapse 了:
然后在 Preview 组件里处理下事件绑定:
function handleEvent(component: Component) {
const props: Record<string, any> = {};
componentConfig[component.name].events?.forEach((event) => {
const eventConfig = component.props[event.name];
if (eventConfig) {
props[event.name] = () => {
eventConfig?.actions?.forEach((action: GoToLinkConfig | ShowMessageConfig) => {
if (action.type === 'goToLink') {
window.location.href = action.url;
} else if (action.type === 'showMessage') {
if (action.config.type === 'success') {
message.success(action.config.text);
} else if (action.config.type === 'error') {
message.error(action.config.text);
}
}
})
}
}
})
return props;
}相比之前,就是多了个遍历的过程。
测试下:
添加两个消息提示的动作,可以看到,两个动作都执行了。
最后我们再做下动作的删除就好了:
通过绝对定位在右上角显示一个删除按钮,点击按钮删除对应 index 的 action。
import { Collapse, Input, Select, CollapseProps, Button} from 'antd';
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
import type { ComponentEvent } from '../../stores/component-config';
import { ActionModal } from './ActionModal';
import { useState } from 'react';
import { GoToLinkConfig } from './actions/GoToLink';
import { ShowMessageConfig } from './actions/ShowMessage';
import { DeleteOutlined } from '@ant-design/icons';
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
if (!curComponent) return null;
function deleteAction(event: ComponentEvent, index: number) {
if(!curComponent) {
return;
}
const actions = curComponent.props[event.name]?.actions;
actions.splice(index, 1)
updateComponentProps(curComponent.id, {
[event.name]: {
actions: actions
}
})
}
const items: CollapseProps['items'] = (componentConfig[curComponent.name].events || []).map(event => {
return {
key: event.name,
label: <div className='flex justify-between leading-[30px]'>
{event.label}
<Button type="primary" onClick={(e) => {
e.stopPropagation();
setCurEvent(event);
setActionModalOpen(true);
}}>添加动作</Button>
</div>,
children: <div>
{
(curComponent.props[event.name]?.actions || []).map((item: GoToLinkConfig | ShowMessageConfig, index: number) => {
return <div>
{
item.type === 'goToLink' ? <div className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>跳转链接</div>
<div>{item.url}</div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
{
item.type === 'showMessage' ? <div className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>消息弹窗</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
</div>
})
}
</div>
}
})
function handleModalOk(config?: GoToLinkConfig | ShowMessageConfig) {
if(!config || !curEvent || !curComponent) {
return ;
}
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config
]
}
})
setActionModalOpen(false)
}
return <div className='px-[10px]'>
<Collapse className='mb-[10px]' items={items} defaultActiveKey={componentConfig[curComponent.name].events?.map(item =>item.name)}/>
<ActionModal visible={actionModalOpen} handleOk={handleModalOk} handleCancel={() => {
setActionModalOpen(false)
}}/>
</div>
}删除成功,json 也修改了。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard c85c9913270242f216ec28d18f03cb64887475b4总结
之前我们是直接在 Setting 区域展示的动作表单,动作多了以后不好展示,这节我们实现了动作选择弹窗。
选择一个动作,填入信息之后,点击添加就可以添加到 actions 里。
在预览的时候会同时执行多个动作。
主流的低代码编辑器的添加动作的交互都是这么做的。
前面实现了内置的几个动作,这节来实现下自定义 JS。
比如 amis:
它就支持通过代码来自定义动作。
而且自定义 JS 可以拿到 doAction 方法来执行其他动作:
可以通过 context 拿到组件信息。
我们也来实现下。
创建 Setting/actions/CustomJS.tsx
import { useState } from "react";
import { useComponetsStore } from "../../../stores/components";
import MonacoEditor, { OnMount } from '@monaco-editor/react'
export interface CustomJSConfig {
type: 'customJS',
code: string
}
export interface CustomJSProps {
defaultValue?: string
onChange?: (config: CustomJSConfig) => void
}
export function CustomJS(props: CustomJSProps) {
const { defaultValue, onChange } = props;
const { curComponentId } = useComponetsStore();
const [value, setValue] = useState(defaultValue);
function codeChange(value?: string) {
if (!curComponentId) return;
setValue(value);
onChange?.({
type: 'customJS',
code: value!
})
}
const handleEditorMount: OnMount = (editor, monaco) => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyJ, () => {
editor.getAction('editor.action.formatDocument')?.run()
});
}
return <div className='mt-[40px]'>
<div className='flex items-start gap-[20px]'>
<div>自定义 JS</div>
<div>
<MonacoEditor
width={'600px'}
height={'400px'}
path='action.js'
language='javascript'
onMount={handleEditorMount}
onChange={codeChange}
value={value}
options={
{
fontSize: 14,
scrollBeyondLastLine: false,
minimap: {
enabled: false,
},
scrollbar: {
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6,
},
}
}
/>
</div>
</div>
</div>
}和其他动作表单不同的是这里用 monaco editor。
然后在 ActionModal 里用一下:
切换自定义 JS 的 tab 时,渲染 CustomJS 组件。
顺便把类型也改一下,加上 CustomJSConfig 的类型
import { Modal, Segmented } from "antd";
import { useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";
export interface ActionModalProps {
visible: boolean
handleOk: (config?: ActionConfig) => void
handleCancel: () => void
}
export type ActionConfig = GoToLinkConfig | ShowMessageConfig | CustomJSConfig;
export function ActionModal(props: ActionModalProps) {
const {
visible,
handleOk,
handleCancel
} = props;
const [key, setKey] = useState<string>('访问链接');
const [curConfig, setCurConfig] = useState<ActionConfig>();
return <Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}
>
<div className="h-[500px]">
<Segmented value={key} onChange={setKey} block options={['访问链接', '消息提示', '自定义 JS']} />
{
key === '访问链接' && <GoToLink onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '消息提示' && <ShowMessage onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '自定义 JS' && <CustomJS onChange={(config) => {
setCurConfig(config);
}}/>
}
</div>
</Modal>
}ComponentEvent 里渲染的时候也支持 customJS,并改下 ts 类型:
import { Collapse, Input, Select, CollapseProps, Button} from 'antd';
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
import type { ComponentEvent } from '../../stores/component-config';
import { ActionConfig, ActionModal } from './ActionModal';
import { useState } from 'react';
import { DeleteOutlined } from '@ant-design/icons';
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
if (!curComponent) return null;
function deleteAction(event: ComponentEvent, index: number) {
if(!curComponent) {
return;
}
const actions = curComponent.props[event.name]?.actions;
actions.splice(index, 1)
updateComponentProps(curComponent.id, {
[event.name]: {
actions: actions
}
})
}
const items: CollapseProps['items'] = (componentConfig[curComponent.name].events || []).map(event => {
return {
key: event.name,
label: <div className='flex justify-between leading-[30px]'>
{event.label}
<Button type="primary" onClick={(e) => {
e.stopPropagation();
setCurEvent(event);
setActionModalOpen(true);
}}>添加动作</Button>
</div>,
children: <div>
{
(curComponent.props[event.name]?.actions || []).map((item: ActionConfig, index: number) => {
return <div>
{
item.type === 'goToLink' ? <div key="goToLink" className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>跳转链接</div>
<div>{item.url}</div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
{
item.type === 'showMessage' ? <div key="showMessage" className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>消息弹窗</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
{
item.type === 'customJS' ? <div key="customJS" className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>自定义 JS</div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
</div>
})
}
</div>
}
})
function handleModalOk(config?: ActionConfig) {
if(!config || !curEvent || !curComponent) {
return ;
}
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config
]
}
})
setActionModalOpen(false)
}
return <div className='px-[10px]'>
<Collapse className='mb-[10px]' items={items} defaultActiveKey={componentConfig[curComponent.name].events?.map(item =>item.name)}/>
<ActionModal visible={actionModalOpen} handleOk={handleModalOk} handleCancel={() => {
setActionModalOpen(false)
}}/>
</div>
}测试下:
动作添加成功。
在 json 里可以看到这个配置:
接下来只要 Preview 的时候实现这种 action 的执行就好了。
支持 customJS 的 action 执行,顺便改下类型。
props[event.name] = () => {
eventConfig?.actions?.forEach((action: ActionConfig) => {
if (action.type === 'goToLink') {
window.location.href = action.url;
} else if (action.type === 'showMessage') {
if (action.config.type === 'success') {
message.success(action.config.text);
} else if (action.config.type === 'error') {
message.error(action.config.text);
}
} else if(action.type === 'customJS') {
const func = new Function(action.code);
func()
}
})
}测试下:
这样就实现了自定义 JS 的执行。
然后给执行的函数加上一些参数:
new Function 可以传入任意个参数,最后一个是函数体,前面都会作为函数参数的名字。
然后调用的时候传入参数。
我们这里只传入了当前组件的 name、props 还有一个方法。
const func = new Function('context', action.code);
func({
name: component.name,
props: component.props,
showMessage(content: string) {
message.success(content)
}
});测试下:
这样,自定义 JS 的功能就完成了。
但现在有个问题:
我们上节做了动作的新增、删除,并没有做编辑。
这对于跳转链接、消息弹窗这种动作还好,参数比较简单。
但是对于自定义 JS,写一段 JS 成本还是挺高的,删了再重写体验不好,所以我们得支持下编辑。
改下 ComponentEvent 组件:
<div style={{ position: 'absolute', top: 10, right: 30, cursor: 'pointer' }}
onClick={() => editAction(item)}
><EditOutlined /></div>加一个绝对定位的 icon。
点击的时候打开弹窗:
function editAction(config: ActionConfig) {
if(!curComponent) {
return;
}
setActionModalOpen(true);
}测试下:
能打开弹窗,但是还没回显内容。
在 ActionModal 传入 action 来回显:
import { Modal, Segmented } from "antd";
import { useEffect, useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";
export type ActionConfig = GoToLinkConfig | ShowMessageConfig | CustomJSConfig;
export interface ActionModalProps {
visible: boolean
action?: ActionConfig
handleOk: (config?: ActionConfig) => void
handleCancel: () => void
}
export function ActionModal(props: ActionModalProps) {
const {
visible,
action,
handleOk,
handleCancel
} = props;
const map = {
goToLink: '访问链接',
showMessage: '消息提示',
customJS: '自定义 JS'
}
const [key, setKey] = useState<string>('访问链接');
const [curConfig, setCurConfig] = useState<ActionConfig>();
useEffect(() => {
if(action?.type ) {
setKey(map[action.type]);
}
}, [action]);
return <Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}
>
<div className="h-[500px]">
<Segmented value={key} onChange={setKey} block options={['访问链接', '消息提示', '自定义 JS']} />
{
key === '访问链接' && <GoToLink key="goToLink" defaultValue={action?.type === 'goToLink' ? action.url : ''} onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '消息提示' && <ShowMessage key="showMessage" value={action?.type === 'showMessage' ? action.config : undefined} onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '自定义 JS' && <CustomJS key="customJS" defaultValue={action?.type === 'customJS' ? action.code : ''} onChange={(config) => {
setCurConfig(config);
}}/>
}
</div>
</Modal>
}然后在 ComponentEvent 里传入这个参数:
const [curAction, setCurAction] = useState<ActionConfig>();测试下:
这样,回显就完成了。
然后保存的时候也要处理下:
记录下当前编辑的 action 的 index。
保存的时候如果有 curAction,就是修改,没有的话才是新增。
import { Collapse, Input, Select, CollapseProps, Button} from 'antd';
import { useComponetsStore } from '../../stores/components';
import { useComponentConfigStore } from '../../stores/component-config';
import type { ComponentEvent } from '../../stores/component-config';
import { ActionConfig, ActionModal } from './ActionModal';
import { useState } from 'react';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
export function ComponentEvent() {
const { curComponentId, curComponent, updateComponentProps } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [actionModalOpen, setActionModalOpen] = useState(false);
const [curEvent, setCurEvent] = useState<ComponentEvent>();
const [curAction, setCurAction] = useState<ActionConfig>();
const [curActionIndex, setCurActionIndex] = useState<number>();
if (!curComponent) return null;
function deleteAction(event: ComponentEvent, index: number) {
if(!curComponent) {
return;
}
const actions = curComponent.props[event.name]?.actions;
actions.splice(index, 1)
updateComponentProps(curComponent.id, {
[event.name]: {
actions: actions
}
})
}
function editAction(config: ActionConfig, index: number) {
if(!curComponent) {
return;
}
setCurAction(config);
setCurActionIndex(index)
setActionModalOpen(true);
}
const items: CollapseProps['items'] = (componentConfig[curComponent.name].events || []).map(event => {
return {
key: event.name,
label: <div className='flex justify-between leading-[30px]'>
{event.label}
<Button type="primary" onClick={(e) => {
e.stopPropagation();
setCurEvent(event);
setActionModalOpen(true);
}}>添加动作</Button>
</div>,
children: <div>
{
(curComponent.props[event.name]?.actions || []).map((item: ActionConfig, index: number) => {
return <div>
{
item.type === 'goToLink' ? <div key="goToLink" className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>跳转链接</div>
<div>{item.url}</div>
<div style={{ position: 'absolute', top: 10, right: 30, cursor: 'pointer' }}
onClick={() => editAction(item, index)}
><EditOutlined /></div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
{
item.type === 'showMessage' ? <div key="showMessage" className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>消息弹窗</div>
<div>{item.config.type}</div>
<div>{item.config.text}</div>
<div style={{ position: 'absolute', top: 10, right: 30, cursor: 'pointer' }}
onClick={() => editAction(item, index)}
><EditOutlined /></div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
{
item.type === 'customJS' ? <div key="customJS" className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>自定义 JS</div>
<div style={{ position: 'absolute', top: 10, right: 30, cursor: 'pointer' }}
onClick={() => editAction(item, index)}
><EditOutlined /></div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}
</div>
})
}
</div>
}
})
function handleModalOk(config?: ActionConfig) {
if(!config || !curEvent || !curComponent) {
return ;
}
if(curAction) {
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: curComponent.props[curEvent.name]?.actions.map((item: ActionConfig, index: number) => {
return index === curActionIndex ? config : item;
})
}
})
} else {
updateComponentProps(curComponent.id, {
[curEvent.name]: {
actions: [
...(curComponent.props[curEvent.name]?.actions || []),
config
]
}
})
}
setCurAction(undefined);
setActionModalOpen(false)
}
return <div className='px-[10px]'>
<Collapse className='mb-[10px]' items={items} defaultActiveKey={componentConfig[curComponent.name].events?.map(item =>item.name)}/>
<ActionModal visible={actionModalOpen} handleOk={handleModalOk} action={curAction} handleCancel={() => {
setCurAction(undefined);
setActionModalOpen(false)
}}/>
</div>
}测试下:
action 的新增和修改正常。
这时候我发现虽然最终保存的是对的,回显的不对:
如上图,我修改下面的 action 的时候,回显的依然是之前的值,但保存是对的。
这是为什么呢?我们不是传了参数了么:
因为我们是用非受控模式写的,传的参数作为表单的默认值:
所以修改 defaultValue 并不会修改表单值。
有回显需求的表单,必须用受控模式来写。
我们改一下:
当传入 value 参数的时候,同步设置内部的 value
测试下:
这样就好了。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 29562eb568bdc05e4efbdd02ba4f817f47201279总结
这节我们实现了自定义 JS。
通过 monaco editor 来输入代码,然后通过 new Function 来动态执行代码,执行的代码可以访问 context,传入一些属性方法。
然后我们实现了动作的编辑,点击编辑按钮会在弹窗回显 action,保存之后会修改 json。
主要回显的表单一定是受控模式,这样才可以随时 value,不然只能设置初始值 defaultValue
这样,内置动作、自定义 JS 的动作就都完成了。
这节我们来实现组件联动。
它是动作的一种类型:
比如 amis 里,点击按钮的时候修改视频组件为隐藏。
这种组件和组件之间的关联就叫组件联动:
那它是怎么实现的呢?
其实也很简单:
我们知道,forwardRef + useImperativeHandle 可以让组件暴露一些方法出来:
我们在递归渲染组件 renderComponents 的时候,把组件 ref 收集起来,放到一个 map 里。
key 为组件 id
{
1111: {
aaa() {
}
bbb() {
}
},
222: {
ccc() {
}
ddd() {
}
}
}这样 id 为 111 的组件想调用 id 为 222 的组件的 ccc 方法,就只需要在动作里加一个配置:
actions: [
{
type: 'componentMethod',
config: {
componentId: 222,
method: 'ccc'
}
}
]然后处理事件的时候,根据这个 componentId 和 method 从 refs 里拿到对应的方法执行就好了。
这样就实现了组件联动。
这个 actions 是配置在 components 的 store 里。
而组件有什么 method 是配置在 componentConfig 的 store 里。
思路理清了,我们来写下代码:
当然,现在的组件没啥好暴露的方法,我们加一个 Modal 组件:
materials/Modal/prod.tsx
import { Modal as AntdModal } from 'antd';
import { forwardRef, useImperativeHandle, useState } from 'react';
import { CommonComponentProps } from '../../interface';
export interface ModalRef {
open: () => void
close: () => void
}
const Modal: React.ForwardRefRenderFunction<ModalRef, CommonComponentProps> = ({ children, title, onOk, onCancel, styles }, ref) => {
const [open, setOpen] = useState(false);
useImperativeHandle(ref, () => {
return {
open: () => {
setOpen(true);
},
close: () => {
setOpen(false);
}
}
}, []);
return (
<AntdModal
title={title}
style={styles}
open={open}
onCancel={() => {
onCancel && onCancel();
setOpen(false);
}}
onOk={() => {
onOk && onOk();
}}
destroyOnClose
>
{children}
</AntdModal>
);
}
export default forwardRef(Modal);可以传入 title、onOk、onCancel、styles 的参数,并且暴露了 open、close 方法用于控制弹窗显示隐藏。
然后写下 dev 时的组件:
materials/Modal/dev.tsx
import { useMaterailDrop } from '../../hooks/useMaterailDrop';
import { CommonComponentProps } from '../../interface';
function Modal({ id, children, title, styles }: CommonComponentProps) {
const {canDrop, drop } = useMaterailDrop(['Button', 'Container'], id);
return (
<div
ref={drop}
style={styles}
data-component-id={id}
className={`min-h-[100px] p-[20px] ${ canDrop ? 'border-[2px] border-[blue]' : 'border-[1px] border-[#000]'}`}
>
<h4>{title}</h4>
<div>
{children}
</div>
</div>
);
}
export default Modal;dev 时的组件和 prod 时的组件不一样,我们要加上 drop 的处理,,设置 drop 时的高亮,添加 data-compnent-id,并且指定最小高度
在 componentConfig 里配一下:
Modal: {
name: 'Modal',
defaultProps: {
title: '弹窗'
},
setter: [
{
name: 'title',
label: '标题',
type: 'input'
}
],
stylesSetter: [],
events: [
{
name: 'onOk',
label: '确认事件',
},
{
name: 'onCancel',
label: '取消事件'
},
],
desc: '弹窗',
dev: ModalDev,
prod: ModalProd
},试下效果:
编辑时可以拖拽组件进去,预览时为空,因为默认是隐藏的。
我们先改为默认显示试试:
然后我们设置下属性和样式:
绑定下事件:
和之前的功能能无缝结合。
低代码编辑器的核心完成后,支持不同场景只要增加不同组件就可以了。
然后我们回过头来继续做组件联动:
默认弹窗是隐藏的,我们要通过组件联动的方式,调用它的 open、close 方法来控制。
在 componentConfig 里配置下这两个 methods:
export interface ComponentMethod {
name: string
label: string
}
export interface ComponentConfig {
name: string;
defaultProps: Record<string, any>,
desc: string;
setter?: ComponentSetter[];
stylesSetter?: ComponentSetter[];
events?: ComponentEvent[];
methods?: ComponentMethod[]
dev: any;
prod: any;
}
methods: [
{
name: 'open',
label: '打开弹窗',
},
{
name: 'close',
label: '关闭弹窗'
}
],然后在 ActionModal 里支持选择组件联动的方式:
import { Modal, Segmented } from "antd";
import { useEffect, useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";
import { ComponentMethod, ComponentMethodConfig } from "./actions/ComponentMethod";
export type ActionConfig = GoToLinkConfig | ShowMessageConfig | CustomJSConfig | ComponentMethodConfig;
export interface ActionModalProps {
visible: boolean
action?: ActionConfig
handleOk: (config?: ActionConfig) => void
handleCancel: () => void
}
export function ActionModal(props: ActionModalProps) {
const {
visible,
action,
handleOk,
handleCancel
} = props;
const map = {
goToLink: '访问链接',
showMessage: '消息提示',
customJS: '自定义 JS',
componentMethod: '组件方法'
}
const [key, setKey] = useState<string>('访问链接');
const [curConfig, setCurConfig] = useState<ActionConfig>();
useEffect(() => {
if(action?.type ) {
setKey(map[action.type]);
}
}, [action]);
return <Modal
title="事件动作配置"
width={800}
open={visible}
okText="确认"
cancelText="取消"
onOk={() => handleOk(curConfig)}
onCancel={handleCancel}
>
<div className="h-[500px]">
<Segmented value={key} onChange={setKey} block options={['访问链接', '消息提示', '组件方法', '自定义 JS']} />
{
key === '访问链接' && <GoToLink key="goToLink" value={action?.type === 'goToLink' ? action.url : ''} onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '消息提示' && <ShowMessage key="showMessage" value={action?.type === 'showMessage' ? action.config : undefined} onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '组件方法' && <ComponentMethod key="showMessage" value={action?.type === 'componentMethod' ? action.config : undefined} onChange={(config) => {
setCurConfig(config);
}}/>
}
{
key === '自定义 JS' && <CustomJS key="customJS" value={action?.type === 'customJS' ? action.code : ''} onChange={(config) => {
setCurConfig(config);
}}/>
}
</div>
</Modal>
}实现下这个 ComponentMethod 组件:
Setting/actions/ComponentMethod.tsx
import { useEffect, useState } from "react";
import { Component, getComponentById, useComponetsStore } from "../../../stores/components";
import { Select, TreeSelect } from "antd";
import { useComponentConfigStore } from "../../../stores/component-config";
export interface ComponentMethodConfig {
type: 'componentMethod',
config: {
componentId: number,
method: string
}
}
export interface ComponentMethodProps {
value?: string
onChange?: (config: ComponentMethodConfig) => void
}
export function ComponentMethod(props: ComponentMethodProps) {
const { components, curComponentId } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [selectedComponent, setSelectedComponent] = useState<Component | null>();
function componentChange(value: number) {
if (!curComponentId) return;
setSelectedComponent(getComponentById(value, components))
}
return <div className='mt-[40px]'>
<div className='flex items-center gap-[10px]'>
<div>组件:</div>
<div>
<TreeSelect
style={{ width: 500, height: 50 }}
treeData={components}
fieldNames={{
label: 'name',
value: 'id',
}}
onChange={(value) => { componentChange(value) }}
/>
</div>
</div>
{componentConfig[selectedComponent?.name || ''] && (
<div className='flex items-center gap-[10px] mt-[20px]'>
<div>方法:</div>
<div>
<Select
style={{ width: 500, height: 50 }}
options={componentConfig[selectedComponent?.name || ''].methods?.map(
method => ({ label: method.label, value: method.name })
)}
onChange={(value) => { }}
/>
</div>
</div>
)}
</div>
}就是两个 Select,一个选择组件、一个选择组件的方法。
需要加一个 selectedComponent 的 state 来记录选中的组件。
测试下:
这样,组件方法的选择就完成了。
我们再处理下 value 和 onChange,做下数据的保存和回显:
import { useEffect, useState } from "react";
import { Component, getComponentById, useComponetsStore } from "../../../stores/components";
import { Select, TreeSelect } from "antd";
import { useComponentConfigStore } from "../../../stores/component-config";
export interface ComponentMethodConfig {
type: 'componentMethod',
config: {
componentId: number,
method: string
}
}
export interface ComponentMethodProps {
value?: ComponentMethodConfig['config']
onChange?: (config: ComponentMethodConfig) => void
}
export function ComponentMethod(props: ComponentMethodProps) {
const { value, onChange} = props;
const { components, curComponentId } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [selectedComponent, setSelectedComponent] = useState<Component | null>();
const [curId, setCurId] = useState<number>();
const [curMethod, setCurMethod] = useState<string>();
useEffect(() => {
if(value) {
setCurId(value.componentId)
setCurMethod(value.method)
setSelectedComponent(getComponentById(value.componentId, components))
}
}, [value]);
function componentChange(value: number) {
if (!curComponentId) return;
setCurId(value);
setSelectedComponent(getComponentById(value, components))
}
function componentMethodChange(value: string) {
if (!curComponentId || !selectedComponent) return;
setCurMethod(value);
onChange?.({
type: 'componentMethod',
config: {
componentId: selectedComponent?.id,
method: value
}
})
}
return <div className='mt-[40px]'>
<div className='flex items-center gap-[10px]'>
<div>组件:</div>
<div>
<TreeSelect
style={{ width: 500, height: 50 }}
treeData={components}
fieldNames={{
label: 'name',
value: 'id',
}}
value={curId}
onChange={(value) => { componentChange(value) }}
/>
</div>
</div>
{componentConfig[selectedComponent?.name || ''] && (
<div className='flex items-center gap-[10px] mt-[20px]'>
<div>方法:</div>
<div>
<Select
style={{ width: 500, height: 50 }}
options={componentConfig[selectedComponent?.name || ''].methods?.map(
method => ({ label: method.label, value: method.name })
)}
value={curMethod}
onChange={(value) => { componentMethodChange(value) }}
/>
</div>
</div>
)}
</div>
}然后还要在动作列表回显下:
{
item.type === 'componentMethod' ? <div key="componentMethod" className='border border-[#aaa] m-[10px] p-[10px] relative'>
<div className='text-[blue]'>组件方法</div>
<div>{getComponentById(item.config.componentId, components)?.desc}</div>
<div>{item.config.componentId}</div>
<div>{item.config.method}</div>
<div style={{ position: 'absolute', top: 10, right: 30, cursor: 'pointer' }}
onClick={() => editAction(item, index)}
><EditOutlined /></div>
<div style={{ position: 'absolute', top: 10, right: 10, cursor: 'pointer' }}
onClick={() => deleteAction(event, index)}
><DeleteOutlined /></div>
</div> : null
}测试下:
添加、编辑都没问题。
然后我们在 Preview 里做下事件处理:
收集所有的 refs,按照 id 来索引,调用方法的时候根据 componentId 和 method 来调用。
const componentRefs = useRef<Record<string, any>>({});ref: (ref: Record<string, any>) => { componentRefs.current[component.id] = ref; },else if(action.type === 'componentMethod') {
const component = componentRefs.current[action.config.componentId];
if (component) {
component[action.config.method]?.();
}
}测试下:
这样,组件联动就完成了。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 909a148d0145db4b7ce93ce2f16f676f87c37013总结
这节我们实现了组件联动,也就是一个组件可以调用另一个组件的方法。
原理就是组件通过 forwardRef + useImperativeHandle 暴露一些方法,然后在 action 里配置 componentId、method。
这样预览的时候收集所有组件的 ref,事件触发的时候根据配置调用对应 componentId 的对应 method。
这样,我们支持了内置动作、自定义 JS、组件联动,事件绑定的功能就比较完整了。
在 amis 编辑器里,物料拖动到画布区后,还可以拖动改变位置:
现在我们的编辑器没有支持拖动改变位置:
我们来实现下:
其实这个也很简单,就是给物料也加上 useDrag 就可以了。
比如给 Button 加一下:
const [_, drag] = useDrag({
type: 'Button',
item: {
type: 'Button'
}
});现在是能拖动了,但是和从物料区拖过来的 drop 逻辑一样,都是新增组件。
我们得区分下两者。
加上 dragType 属性,然后带上当前拖拽的组件 id:
在 useDrop 的时候判断下 dragTag,如果是 move,那就先 delete 再 add
import { useDrop } from "react-dnd";
import { useComponentConfigStore } from "../stores/component-config";
import { getComponentById, useComponetsStore } from "../stores/components";
export interface ItemType {
type: string;
dragType?: 'move' | 'add',
id: number
}
export function useMaterailDrop(accept: string[], id: number) {
const { addComponent, deleteComponent, components } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
const [{ canDrop }, drop] = useDrop(() => ({
accept,
drop: (item: ItemType, monitor) => {
const didDrop = monitor.didDrop()
if (didDrop) {
return;
}
if(item.dragType === 'move') {
const component = getComponentById(item.id, components)!;
deleteComponent(item.id);
addComponent(component, id)
} else {
const config = componentConfig[item.type];
addComponent({
id: new Date().getTime(),
name: item.type,
desc: config.desc,
props: config.defaultProps
}, id)
}
},
collect: (monitor) => ({
canDrop: monitor.canDrop(),
}),
}));
return { canDrop, drop }
}测试下:
这样就实现了拖拽改变位置。
在 Container 组件也加上 useDrag:
这里因为要同时给 div 绑定 drag、drop 的处理,所以用 useRef 拿到 ref 之后再绑定。
import { useDrag } from 'react-dnd';
import { useMaterailDrop } from '../../hooks/useMaterailDrop';
import { CommonComponentProps } from '../../interface';
import { useEffect, useRef } from 'react';
const Container = ({ id, name, children, styles }: CommonComponentProps) => {
const {canDrop, drop } = useMaterailDrop(['Button', 'Container'], id);
const divRef = useRef<HTMLDivElement>(null);
const [_, drag] = useDrag({
type: name,
item: {
type: name,
dragType: 'move',
id: id
}
});
useEffect(() => {
drop(divRef);
drag(divRef);
}, []);
return (
<div
data-component-id={id}
ref={divRef}
style={styles}
className={`min-h-[100px] p-[20px] ${ canDrop ? 'border-[2px] border-[blue]' : 'border-[1px] border-[#000]'}`}
>{children}</div>
)
}
export default Container;materials/Table/dev.tsx
import { Table as AntdTable } from 'antd';
import React, { useEffect, useMemo, useRef } from 'react';
import { CommonComponentProps } from '../../interface';
import { useMaterailDrop } from '../../hooks/useMaterailDrop';
import { useDrag } from 'react-dnd';
function Table({ id, name, children, styles }: CommonComponentProps) {
const {canDrop, drop } = useMaterailDrop(['TableColumn'], id);
const divRef = useRef<HTMLDivElement>(null);
const [_, drag] = useDrag({
type: name,
item: {
type: name,
dragType: 'move',
id: id
}
});
useEffect(() => {
drop(divRef);
drag(divRef);
}, []);
const columns = useMemo(() => {
return React.Children.map(children, (item: any) => {
return {
title: <div className='m-[-16px] p-[16px]' data-component-id={item.props?.id}>{item.props?.title}</div>,
dataIndex: item.props?.dataIndex,
key: item
}
})
}, [children]);
return (
<div
className={`w-[100%] ${canDrop ? 'border-[2px] border-[blue]' : 'border-[1px] border-[#000]'}`}
ref={divRef}
data-component-id={id}
style={styles}
>
<AntdTable
columns={columns}
dataSource={[]}
pagination={false}
/>
</div>
);
}
export default Table;添加 drop、drag 的处理,用 antd 的 table 来渲染。
这里 columns 的处理比较巧妙:
我们拖拽 TableColumn 组件过来的时候,用 React.Children 遍历,把它变为 columns 配置。
当然,这个 TableColumn 组件还没写。
在 componentConfig 添加 Table 组件的配置:
Table: {
name: 'Table',
defaultProps: {},
desc: '表格',
setter: [
{
name: 'url',
label: 'url',
type: 'input',
},
],
dev: TableDev,
prod: TableDev
}然后在 Page、Modal、Container 组件里支持下 Table 的 drop:
试一下:
没啥问题。
然后再实现下 TableColumn 组件:
materials/TableColumn/dev.tsx
const TableColumn = () => {
return <></>
}
export default TableColumn;materials/TableColumn/prod.tsx
const TableColumn = () => {
return <></>
}
export default TableColumn;这只是我们做 column 配置用的,不需要渲染内容。
在 ColumnConfig 加一下配置:
TableColumn: {
name: 'TableColumn',
desc: '表格列',
defaultProps: {
dataIndex:`col_${new Date().getTime()}`,
title: '列名'
},
setter: [
{
name: 'type',
label: '类型',
type: 'select',
options: [
{
label: '文本',
value: 'text',
},
{
label: '日期',
value: 'date',
},
],
},
{
name: 'title',
label: '标题',
type: 'input',
},
{
name: 'dataIndex',
label: '字段',
type: 'input',
},
],
dev: TableColumnDev,
prod: TableColumnProd,
}试下效果:
我们用 TableColumn 组件来配置字段。
然后再来实现 Table 组件的 prod 版本:
materials/Table/prod.tsx
import { Table as AntdTable } from 'antd';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { CommonComponentProps } from '../../interface';
const Table = ({ url, children }: CommonComponentProps) => {
const [data, setData] = useState<Array<Record<string, any>>>([]);
const [loading, setLoading] = useState(false);
const getData = async () => {
if (url) {
setLoading(true);
const { data } = await axios.get(url);
setData(data);
setLoading(false);
}
}
useEffect(() => {
getData();
}, []);
const columns = useMemo(() => {
return React.Children.map(children, (item: any) => {
if (item?.props?.type === 'date') {
return {
title: item.props?.title,
dataIndex: item.props?.dataIndex,
render: (value: any) => value ? dayjs(value).format('YYYY-MM-DD') : null,
}
} else {
return {
title: item.props?.title,
dataIndex: item.props?.dataIndex,
}
}
})
}, [children]);
return (
<AntdTable
columns={columns}
dataSource={data}
pagination={false}
rowKey="id"
loading={loading}
/>
);
}
export default Table;生产环境的 Table 需要请求 url,拿到数据后设置到 table。
并且渲染列的时候,如果是 date,要用 dayjs 做下格式化。
安装下用到的包:
npm install --save axios
npm install --save dayjs改下 componentConfig 里的组件:
试一下:
可以看到,确实发请求了。
只不过现在没这个接口。
我们用 nest 创建一个后端服务:
npx @nestjs/cli new lowcode-demo-backend改下 AppController,加一个接口:
@Get('data')
data() {
return [
{ name: '光光', sex: '男', birthday: new Date('1994-07-07').getTime() },
{ name: '东东', sex: '男', birthday: new Date('1995-06-06').getTime() },
{ name: '小红', sex: '女', birthday: new Date('1996-08-08').getTime() }
]
}在 main.ts 开启跨域:
把服务跑起来:
npm run start:dev浏览器访问下:
这样接口就有了。
我们再来试下 Table 组件:
添加三个 TableColumn,配置下字段。
然后在 Table 配置下 url:
再点击预览:
这样,Table 组件就会请求 url,然后根据配置渲染表格
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 3df08cf3e09d69817f1bc75bf1b0f9f5e8cb41c4总结
这节我们实现了物料组件拖拽改变位置,并实现了 Table 组件。
拖拽改变位置只要在物料组件上加上 useDrag 就可以了,要注意区分 add 和 move 的情况,加上标识,分别做处理。
Table 组件可以配置 url,然后拖拽 TableColumn 进来,TableColumn 可以配置字段信息。
Preview 渲染的时候,根据 url 请求接口,然后根据 columns 的配置来渲染数据。
这样,Table 的物料组件就完成了。
上节实现了 Table 的物料组件,这节继续来实现 Form 组件。
创建 materails/Form/dev.tsx
import { Form as AntdForm, Input } from 'antd';
import React, { useEffect, useMemo, useRef } from 'react';
import { useMaterailDrop } from '../../hooks/useMaterailDrop';
import { CommonComponentProps } from '../../interface';
import { useDrag } from 'react-dnd';
function Form({ id, name, children, onFinish }: CommonComponentProps) {
const [form] = AntdForm.useForm();
const {canDrop, drop } = useMaterailDrop(['FormItem'], id);
const divRef = useRef<HTMLDivElement>(null);
const [_, drag] = useDrag({
type: name,
item: {
type: name,
dragType: 'move',
id: id
}
});
useEffect(() => {
drop(divRef);
drag(divRef);
}, []);
const formItems = useMemo(() => {
return React.Children.map(children, (item: any) => {
return {
label: item.props?.label,
name: item.props?.name,
type: item.props?.type,
id: item.props?.id,
}
});
}, [children]);
return <div
className={`w-[100%] p-[20px] min-h-[100px] ${canDrop ? 'border-[2px] border-[blue]' : 'border-[1px] border-[#000]'}`}
ref={divRef}
data-component-id={id}
>
<AntdForm labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} form={form} onFinish={(values) =>{
onFinish && onFinish(values)
}}>
{formItems.map((item: any) => {
return <AntdForm.Item key={item.name} data-component-id={item.id} name={item.name} label={item.label} >
<Input style={{pointerEvents: 'none'}}/>
</AntdForm.Item>
})}
</AntdForm>
</div>
}
export default Form;和 Table 的实现方式差不多,可以拖拽 FormItem 进来,然后通过 React.Children.map 变成表单项配置,之后遍历渲染 Form.Item
注意要加上 pointerEvent:none,因为编辑时 Input 不需要输入内容
在 Page、Container、Modal 组件里支持 Form 组件的 drop:
在 componentConfig 里添加 Form 组件的配置:
Form: {
name: 'Form',
defaultProps: {},
desc: '表单',
setter: [
{
name: 'title',
label: '标题',
type: 'input',
},
],
events: [
{
name: 'onFinish',
label: '提交事件',
}
],
dev: FormDev,
prod: FormDev
},测试下:
没啥问题。
然后我们实现 FormItem 组件:
materials/FormItem/dev.tsx
const FormItem = () => <></>;
export default FormItem;materials/FormItem/prod.tsx
const FormItem = () => <></>;
export default FormItem;和 TableColumn 一样,它只是用于配置的,不需要渲染啥。
在 componentConfig 里配置下:
FormItem: {
name: 'FormItem',
desc: '表单项',
defaultProps: {
name: new Date().getTime(),
label: '姓名'
},
dev: FormItemDev,
prod: FormItemProd,
setter: [
{
name: 'type',
label: '类型',
type: 'select',
options: [
{
label: '文本',
value: 'input',
},
{
label: '日期',
value: 'date',
},
],
},
{
name: 'label',
label: '标题',
type: 'input',
},
{
name: 'name',
label: '字段',
type: 'input',
},
{
name: 'rules',
label: '校验',
type: 'select',
options: [
{
label: '必填',
value: 'required',
},
],
}
]
}然后实现下 prod 的 Form 组件:
import { Form as AntdForm, DatePicker, Input } from 'antd';
import React, { forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useMemo } from 'react';
import { CommonComponentProps } from '../../interface';
import dayjs from 'dayjs';
export interface FormRef {
submit: () => void
}
const Form: ForwardRefRenderFunction<FormRef, CommonComponentProps> = ({ children, onFinish }, ref) => {
const [form] = AntdForm.useForm();
useImperativeHandle(ref, () => {
return {
submit: () => {
form.submit();
}
}
}, [form]);
const formItems = useMemo(() => {
return React.Children.map(children, (item: any) => {
return {
label: item.props?.label,
name: item.props?.name,
type: item.props?.type,
id: item.props?.id,
rules: item.props?.rules,
}
});
}, [children]);
async function save(values: any) {
Object.keys(values).forEach(key => {
if (dayjs.isDayjs(values[key])) {
values[key] = values[key].format('YYYY-MM-DD')
}
})
onFinish(values);
}
return <AntdForm name='form' labelCol={{ span: 5 }} wrapperCol={{ span: 18 }} form={form} onFinish={save}>
{formItems.map((item: any) => {
return (
<AntdForm.Item
key={item.name}
name={item.name}
label={item.label}
rules={
item.rules === 'required' ? [{
required: true,
message: '不能为空'
}] : []
}
>
{item.type === 'input' && <Input />}
{item.type === 'date' && <DatePicker />}
</AntdForm.Item>
)
})}
</AntdForm>
}
export default forwardRef(Form);用 React.Children.map 拿到要渲染的 formItems 信息,然后遍历渲染表单项 From.Item,根据类型渲染不同表单。
onFinish 的时候,需要对 DatePicker 的 value 做下处理,因为值是 dayjs 对象,需要 format 一下拿到字符串值。
我们还通过 forwardRef + useImperativeHandle 暴露了 submit 方法,需要在 componentConfig 里注册下:
methods: [
{
name: 'submit',
label: '提交',
}
],并且修改 prod 为刚才写的组件。
测试下看看:
然后我们加一个按钮来触发表单提交。
我们应该能在事件处理函数里拿到传过来的 values:
在 Preview 绑定事件的时候加一下参数:
测试下:
const values = args[0];
alert(JSON.stringify(values))点击按钮触发表单提交的动作。
表单提交触发脚本执行的动作。
我们可以再加一个发送请求的动作,根据传入的 values 来发送创建请求,之后调用 Table 的刷新方法就好了。
现在我们编辑完的画布,一刷新就没有了:
这样体验不好,我们最好做一下持久化。
这是 zustand 自带的功能,用 persist 中间件实现就行:
我们做拖拽版 todolist 那个案例的时候用过:
用了 ts + middleware 的时候,create 要换种写法。
文档的解释是为了更好的处理类型:
不影响功能。
我们加一下:
const creator: StateCreator<State & Action> = (set, get) => ({
//...
});
export const useComponetsStore = create<State & Action>()(persist(creator, {
name: 'xxx'
}));测试下:
这样刷新后依然保存着编辑的内容。
案例代码上传了小册仓库
总结
这节我们实现了 Form 组件,并做了 store 的持久化。
Form 组件和 Table 组件一样,通过 FormItem 来配置字段,FormItem 本身不渲染内容。
Form 暴露了 submit 方法,并且支持绑定 onFinish 事件。
我们可以通过 Button 的点击事件触发 Form 的 submit,然后给 Form 的 onFinish 事件绑定一个发请求的动作,这样就实现了提交表单保存到服务端。
至此,我们的低代码编辑器就比较完善了,物料、动作都可以根据需要自己添加。
我们从 0 到 1 实现了一个低代码编辑器,和 amis 功能类似。
先过一下整体功能:
可以拖拽物料组件到画布区,可以放在任意层次:
并且组件还可以拖拽改变位置:
组件选中之后可以编辑属性:
编辑样式:
还可以绑定事件:
事件可以绑定不同的动作,比如跳转链接、消息提示、自定义 JS、或者调用其他组件的方法:
可以编辑、删除事件绑定的动作:
可以切换大纲、源码视图:
编辑完之后可以预览:
这就是低代码编辑器的全部功能。
其实大多数低代码编辑器都是这样做的。
比如我们看下百度的 amis:
华为的 tiny engine
功能大同小异。
当然,我们的低代码编辑器内置的物料组件不多,你完全可以自己扩展物料,支持各种场景的搭建。
回顾下我们开发的过程:
首先,我们分析了低代码的核心就是一个 json 的数据结构。
这个 json 就是一个通过 children 属性串联的组件对象树。
从物料区拖拽组件到画布区,就是在 json 的某一层级加了一个组件对象。
选中组件在右侧编辑属性,就是修改 json 里某个组件对象的属性。
大纲就是把这个 json 用树形展示。
然后我们写了下代码,用 allomet 实现了 split pane 布局,用 tailwind 来写样式,引入 zustand 来做全局 store。
在 store 中定义了 components 和对应的 add、update、delete 方法。
然后实现了拖拽组件到画布,也就是拖拽编辑 json。
我们添加了 Button 和 Container 组件,并创建了 componentConfig 的全局 store,用来保存组件配置。
然后实现了 renderComponents,它就是递归渲染 component,用到的组件配置从 componentConfig 取。
之后引入 react-dnd 实现了拖拽编辑,左侧的物料添加 useDrag,画布里的组件添加 useDrop,然后当 drop 的时候,在对应 id 下添加一个对应的类型的组件。
还要处理下 didDrop,保证只 drop 一次。
之后我们实现了下编辑的时候的交互,实现了 hover 时的高亮框,和点击时的编辑框。
在每个组件渲染的时候加上了 data-component-id,然后在画布区根组件监听 mouseover、click 事件,通过触发事件的元素一层层往上找,找到 component-id。
然后 getBoudingClientRect 拿到这个元素的 width、height、left、top 等信息,和画布区根元素的位置做计算,算出高亮框、编辑框的位置。
接下来实现了属性和样式的编辑。
在 componentConfig 里加了 setter、stylesSetter 来保存不同组件的属性、样式表单配置。
然后在 Setting 区域渲染对应的表单。
表单变化的时候,修改 components 里对应的 styles、props 信息,传入组件渲染。 然后实现了源码、大纲、预览的功能。
源码和大纲比较简单,就是 json 的不同形式的展示,分别用 @monaco-editor/react 和 Tree 组件来做。
预览功能也是递归渲染 json 为组件树,但是组件不一样,预览和编辑状态的组件要分开写。
我们在 store 加了一个 mode 的状态,切换 mode 来切换渲染的内容。
然后实现了事件绑定:
在 comonentConfig 里配置组件可以绑定的事件,然后在 Setting 区事件面板里展示。
可以选择绑定的动作,比如跳转链接,显示提示,输入一些参数之后,就会保存到 json 里。
然后渲染 Preview 的时候根据这些信息来绑定事件。
但直接在 Setting 区域展示的动作表单,动作多了以后不好展示,于是我们实现了动作选择弹窗。
主流低代码编辑器绑定动作的交互都是这么做的。
然后我们实现了自定义 JS 的动作:
通过 monaco editor 来输入代码,然后通过 new Function 来动态执行代码,执行的代码可以访问 context,传入一些属性方法。
实现了组件联动,也就是一个组件可以调用另一个组件的方法。
原理就是组件通过 forwardRef + useImperativeHandle 暴露一些方法,然后在 action 里配置 componentId、method。
这样预览的时候收集所有组件的 ref,事件触发的时候根据配置调用对应 componentId 的对应 method。
综上,我们支持了内置动作、自定义 JS、组件联动,事件绑定的功能就比较完整了。
然后加了 Table、Form 等物料组件。
Table 组件可以配置 url,然后拖拽 TableColumn 进来,TableColumn 可以配置字段信息。
Preview 渲染的时候,根据 url 请求接口,然后根据 columns 的配置来渲染数据。
Form 组件和 Table 组件一样,通过 FormItem 来配置字段,FormItem 本身不渲染内容。
Form 暴露了 submit 方法,并且支持绑定 onFinish 事件。
我们可以通过 Button 的点击事件触发 Form 的 submit,然后给 Form 的 onFinish 事件绑定一个发请求的动作,这样就实现了提交表单保存到服务端。
你可以基于这个低代码编辑器扩展一些物料、动作,支持某些场景的搭建。
这个项目也有挺多技术亮点的:
- 基于 react-dnd 实现了拖拽,可以拖拽物料到组件树的任意层级
- 通过 zustand 实现了全局 store 的存储,比如组件树、组件配置等,并用 persist 中间件做了持久化
- 通过 tailwind 来写样式,不需要写 css 文件
- 通过 getBoudingClientRect 拿到 hover、click 的组件边界,动态计算编辑框位置
- 通过 json 递归渲染组件,基于 React.cloneElement 来修改组件 props
- 通过 ref 实现了组件联动,组件通过 forwardRef + useImperativeHandle 暴露方法,然后全局注册,供别的组件调用
其实整体做下来,实现一个低代码编辑器并不是很难,难点大概在实现各种物料组件、支持各种属性配置吧 🤔️
很多公司都有团队在做专职做低代码业务,学会这个项目,写在简历上,或许能给你增加一些机会。