IdbI

第72章—低代码编辑器:预览、大纲

这节我们来做下大纲和预览的功能。

大纲就是树形展示组件结构:

顺便把源码也做一下:

预览则是展示编辑好的页面:

我们先来实现下左边的大纲和源码。

创建 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 来切换渲染的内容。

这样,从编辑到预览的流程就打通了。

返回上级