紧接着前面的Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(三)
- 主页面结构:左侧菜单栏可选组件列表、中间容器画布、右侧编辑组件定义的属性;
- 左侧菜单栏可选组件列表渲染;
- 从菜单栏拖拽组件到容器;
- 组件(Block)在容器的选中状态;
- 容器内组件可移动位置;
- 容器内的组件单选、多选、全选;
- 命令队列及对应的快捷键;
- 操作栏按钮:
- 撤销、重做 重难点;
- 删除、清空;
- 预览、关闭编辑模式;
- 置顶、置底;
- 导入、导出;
- 右键菜单;
- 拖拽参考线;
- 组件可以拖动调整高度和宽度(height,width);
- 组件可以设置预定好的属性(props);
- 组件绑定值(model);
- 设置组件标识(soltName),根据这个标识,定义某个组件的行为(函数触发);
- 插槽的实现(自定义视图);
- 完善可选组件列表:
-
输入框:双向绑定值,调整宽度;
-
按钮:类型、文字、大小尺寸、拖拽调整宽高;
-
下拉框:预定义选项值、双向绑定字段;
-
图片:自定义图片地址、拖拽调整图片宽高
-
十七、选中容器和 Block 的属性编辑
编辑容器的宽高
import { Alert, Button, Form, Input, InputNumber, Select } from "antd";
import { FormOutlined, SettingFilled } from '@ant-design/icons';
import deepcopy from "deepcopy";
import { useEffect, useState } from "react";
import {
VisualEditorBlockData,
VisualEditorConfig,
VisualEditorValue,
} from "./editor.utils";
import classModule from './style/VisualEditorOperator.module.scss';
export const VisualEditorOperator: React.FC<{
selectBlock: VisualEditorBlockData;
value: VisualEditorValue;
config: VisualEditorConfig;
updateBlock: (
newBlock: VisualEditorBlockData,
oldBlock: VisualEditorBlockData
) => void;
updateValue: (val: VisualEditorValue) => void;
}> = (props) => {
const [editData, setEditData] = useState({} as any);
const [form] = Form.useForm();
const methods = {
onFormValuesChange: (valuesChange: any, values: any) => {
setEditData({
...editData,
...values,
});
},
apply: () => {
if (!props.selectBlock) {
// 更新整个容器的 value 数据
props.updateValue({
...props.value,
container: editData,
});
} else {
// 更新组件的 block 数据
console.log("更新组件的 block 数据");
}
},
reset: () => {
const data = !props.selectBlock ? deepcopy(props.value.container) : deepcopy(props.selectBlock);
setEditData(data);
form.resetFields();
form.setFieldsValue(data);
},
};
useEffect(() => {
methods.reset();
}, [props.selectBlock]);
const render = (() => {
const content: JSX.Element[] = [];
if (!props.selectBlock) {
// 编辑容器属性
content.push(
<Form.Item label="容器宽度" name="width" key="container-width">
<InputNumber step={100} min={0} precision={0} />
</Form.Item>
);
content.push(
<Form.Item label="容器高度" name="height" key="container-height">
<InputNumber step={100} precision={0} />
</Form.Item>
);
} else {
// 编辑 block 属性
content.push(<>"编辑 block 属性"</>);
}
return content;
})();
return (
<>
<div className={classModule["visual-editor__operator_content"]}>
<div className={classModule["visual-editor__operator_content-title"]}>
<span><FormOutlined /> {props.selectBlock ? "编辑元素" : "编辑容器"}</span>
<span><SettingFilled /> 动画</span>
</div>
<div className={classModule["visual-editor__operator_content-option"]}>
<Form
layout="vertical"
style={{ textAlign: "center" }}
form={form}
onValuesChange={methods.onFormValuesChange}
>
{render}
<Form.Item key="operator">
<Button
type="primary"
onClick={methods.apply}
style={{ marginRight: "8px" }}
>
应用
</Button>
<Button onClick={methods.reset}>重置</Button>
</Form.Item>
</Form>
</div>
</div>
</>
);
};
Block 属性编辑
import { Alert, Button, Form, Input, InputNumber, Select } from "antd";
import { FormOutlined, SettingFilled } from '@ant-design/icons';
import deepcopy from "deepcopy";
import { useEffect, useState } from "react";
import { SketchPicker } from "react-color";
// other ....
export const VisualEditorOperator: React.FC<{
selectBlock: VisualEditorBlockData;
value: VisualEditorValue;
config: VisualEditorConfig;
updateBlock: (
newBlock: VisualEditorBlockData,
oldBlock: VisualEditorBlockData
) => void;
updateValue: (val: VisualEditorValue) => void;
}> = (props) => {
const [editData, setEditData] = useState({} as any);
const [form] = Form.useForm();
// other ....
useEffect(() => {
methods.reset();
}, [props.selectBlock]);
const renderEditor = (
propsName: string,
propsConfig: VisualEditorProps,
index: number,
apply: () => void
) => {
switch (propsConfig.type) {
case VisualEditorPropsType.text:
return (
<Form.Item
label={propsConfig.name}
name={["props", propsName]}
key={`propsName_${index}`}
>
<Input />
</Form.Item>
);
case VisualEditorPropsType.select:
return (
<Form.Item
label={propsConfig.name}
name={["props", propsName]}
key={`propsName_${index}`}
>
<Select>
{propsConfig.options!.map((opt, i) => (
<Select.Option value={opt.value} key={i}>
{opt.label}
</Select.Option>
))}
</Select>
</Form.Item>
);
case VisualEditorPropsType.color:
return (
<Form.Item
label={propsConfig.name}
name={["props", propsName]}
key={`propsName_${index}`}
>
<SketchPicker />
</Form.Item>
);
case VisualEditorPropsType.table:
return (
<Form.Item
label={propsConfig.name}
name={["props", propsName]}
key={`propsName_${index}`}
>
{/* 编辑数据自动触发点击"应用"事件自动更新数据 */}
<p>编辑数据自动触发点击"应用"事件自动更新数据</p>
</Form.Item>
);
default:
return (
<Alert
message="propsConfig.type is not exist!"
type="error"
showIcon
key="error"
/>
);
}
};
const render = (() => {
const content: JSX.Element[] = [];
if (!props.selectBlock) {
// content.push(<div key="block">"编辑容器属性"</div>);
// other ....
} else {
// content.push(<div key="block">"编辑 block 属性"</div>);
// 编辑 block 属性
const component =
props.config.componentMap[props.selectBlock.componentKey];
if (component) {
content.push(
<Form.Item label="组件标识" name="slotName" key="slotName">
<Input />
</Form.Item>
);
content.push(
...Object.entries(component.props || {}).map(
([propsName, propsConfig], index) => {
return renderEditor(
propsName,
propsConfig,
index,
methods.apply
)!;
}
)
);
}
}
return content;
})();
return (
<>
{/*other ...*/}
</>
);
};
下拉选择的编辑表格
import { useState } from 'react';
import ReactDOM from 'react-dom';
import { Button, Modal, Tag, Table, Input } from 'antd';
import { PlusOutlined } from "@ant-design/icons";
import deepcopy from 'deepcopy';
import "./table-prop.scss";
import { VisualEditorTableProp } from '../../editor-props';
import { defer } from '../../utils/defer';
export const VisualEditorTablePropCom: React.FC<{
config: VisualEditorTableProp,
value?: any[],
onChange?: (val?: any[]) => void,
}> = (props) => {
const methods = {
openEdit: async () => {
const newVal = await TablePropEditService({
config: props.config,
value: props.value!
});
props.onChange && props.onChange(newVal)
}
};
let render: any;
if (!props.value || props.value.length === 0) {
render = (
<Button onClick={methods.openEdit}>
<PlusOutlined />
<span>编辑</span>
</Button>
);
} else {
render = props.value.map((item, index) => {
return (
<Tag key={index} onClick={methods.openEdit}>
{
item[props.config.showField]
}
</Tag>
);
});
}
return (
<div className="visual-editor-table__props">
{render}
</div>
);
}
interface TablePropEditServiceOption {
config: VisualEditorTableProp,
value?: any[],
onConfirm?: (val?: any[]) => void
}
const nextKey = (() => {
let index = 0;
const start = Date.now()
return () => start + '_' + index++
})();
const TablePropEditModal: React.FC<{ option: TablePropEditServiceOption, onRef: (ins: { show: (opt: TablePropEditServiceOption) => void }) => void }> = (props) => {
let [option, setOption] = useState(props.option || {});
let [showFlag, setShowFlag] = useState(false);
let [editData, setEditDatas] = useState([] as any[]);
// 二次处理数据
const setEditData = (val: any[]) => {
// 如果数组中没有 key 属性,再次处理生成 key 属性,否则报错
return setEditDatas(val.map(d => {
!d.key && (d.key = nextKey());
return d;
}));
};
const methods = {
show: (opt: TablePropEditServiceOption) => {
setOption(opt);
setEditData(deepcopy(opt.value || []));
setShowFlag(true);
},
close: () => {
setShowFlag(false);
},
save: () => {
option.onConfirm && option.onConfirm(editData);
methods.close();
},
add: () => {
setEditData([
{},
...editData
]);
},
reset: () => {
setEditData(option.value || []);
}
};
props?.onRef(methods);// 挂载方法
return (
<Modal
visible={showFlag}
footer={(<>
<Button onClick={methods.close}>取消</Button>
<Button type="primary" onClick={methods.save}>保存</Button>
</>)}
onCancel={methods.close}
width="800px"
>
<div className="table-prop-editor__dialog-buttons">
<Button onClick={methods.add} type="primary" style={{ marginRight: '8px' }}>添加</Button>
<Button onClick={methods.reset}>重置</Button>
</div>
<div className="table-prop-editor__dialog-list">
<Table dataSource={editData}>
<Table.Column
dataIndex={''}
title={"#"}
render={(_1, _2, index) => {
return index + 1;
}} />
{(option.config.columns || []).map((col, index) => (
<Table.Column
title={col.name}
dataIndex={col.field}
key={index}
render={(_1, row: any, index) => {
return (
<Input
value={row[col.field]}
onChange={e => {
row = { ...row, [col.field]: e.target.value }
editData[index] = row
setEditData([...editData])
}}
/>
)
}}
/>
))}
<Table.Column
title="操作栏"
render={(_1, _2, index) => {
return (
<Button onClick={() => {
editData.splice(index, 1);
setEditData(editData);
}
}
type={"text"}
>
删除
</Button>
)
}}
/>
</Table>
</div>
</Modal>
);
}
export const TablePropEditService = (() => {
let ins: any;
return (options: Omit<TablePropEditServiceOption, 'onConfirm'>): Promise<undefined | any[]> => {
const dfd = defer<undefined | any[]>();
options = {
...options,
onConfirm: dfd.resolve
} as TablePropEditServiceOption;
if (!ins) {
const el = document.createElement('div');
document.body.appendChild(el);
ReactDOM.render(<TablePropEditModal option={options} onRef={v => ins = v} />, el);
}
ins.show(options);
return dfd.promise;
}
})();
block绑定对应的数据字段
// model
content.push(
...Object.entries(component.model || {}).map(([modelProp, modelName], index) => {
return (
<Form.Item label={modelName} name={['model', modelProp]} key={`model_${index}`}>
<Input />
</Form.Item>
);
})
);
绑定多个字段
import "./number-range.scss";
import { useMemo, useState } from "react";
export const NumberRange: React.FC<{
start?: string;
end?: string;
onStartChange?: (val?: string) => void;
onEndChange?: (val?: string) => void;
width?: number | string;
}> = (props) => {
const [start, setStart] = useState(props.start);
const [end, setEnd] = useState(props.end);
const handler = {
onStartChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setStart(val);
!!props.onStartChange && props.onStartChange(val);
},
onEndChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setEnd(val);
!!props.onEndChange && props.onEndChange(val);
},
};
const styles = useMemo(() => {
let width = props.width;
if (!width) {
width = "225px";
}
if (typeof width === "number") {
width = `${width}px`;
}
return {
width,
};
}, [props.width]);
return (
<div className="number-range" style={styles}>
<input
type="text"
defaultValue={start}
onChange={handler.onStartChange}
/>
<i>~</i>
<input type="text" defaultValue={end} onChange={handler.onEndChange} />
</div>
);
};
图片编辑
visualConfig.registryComponent("image", {
label: "图片",
render: ({ props, size }) => {
return (
<div
style={{ height: size.height || "100px", width: size.width || "100px" }}
className="visual-block-image"
>
<img
src={props && props.url || './img/default-img.jpg'}
style={{
objectFit: "fill",
display: "block",
height: "100%",
width: "100%",
}}
/>
</div>
);
},
preview: () => (
<div
style={
{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "100px",
height: "50px",
fontSize: "20px",
color: "#ccc",
backgroundColor: "#f2f2f2",
}
}
>
<PictureOutlined />
</div>
),
resize: {
width: true,
height: true,
},
props: {
url: createTextProp("地址"),
},
});
自定义 Block 的事件行为
为每个组件传递 customProps 属性,挂载即可
自定义 Block 的插槽
为组件指定对应的组件标识做对应的编码和传递 children 属性,挂载即可