这节我们来做属性编辑的功能。
在 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 的表单配置不够完善,当后面新加组件的时候,需要什么表单类型再扩展就行。
