这节我们来做下大纲和预览的功能。
大纲就是树形展示组件结构:
顺便把源码也做一下:
预览则是展示编辑好的页面:
我们先来实现下左边的大纲和源码。
创建 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 来切换渲染的内容。
这样,从编辑到预览的流程就打通了。
