Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(四)

514 阅读3分钟

紧接着前面的Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(三)

Vue3版本请点击

  • 主页面结构:左侧菜单栏可选组件列表、中间容器画布、右侧编辑组件定义的属性;
  • 左侧菜单栏可选组件列表渲染;
  • 从菜单栏拖拽组件到容器;
  • 组件(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 属性编辑

block的相关属性编辑.png

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 ...*/}
    </>
  );
};

传送门

下拉选择的编辑表格

下拉选择的编辑.png

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绑定对应的数据字段

绑定单个字段.png

// 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>
    );
  })
);

绑定多个字段

绑定多个字段.png

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>
  );
};

图片编辑

图片编辑.png

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 属性,挂载即可