react 简单快速实现form表单拖拽生成

239 阅读4分钟

先看看效果

GIF.gif

整体布局

目录 image.png index 代码主体 由于涉及到夸组件联动 所以使用了createContext存储

const TemplateContext = createContext(null)
const TemplateSettings: React.FC<{}> = () => {
  const actionRef = useRef(null)

  const [currentControl, setCurrentControl] = useState<controlDataType>();

  const setControlVal = (val: controlDataType) => {
    if (actionRef?.current?.setControlIndexVal) {
      actionRef.current.setControlIndexVal(val)
    }
  }
    // currentControl :当前选中的组件 setCurrentControl:设置选中组件的值
    // setControlVal :设置模板当前选中dom的虚拟数据
  return <PageHeaderWrapper>
    <TemplateContext.Provider value={{ currentControl, setCurrentControl, setControlVal }}>
      <div className={styles.settingsMain}>
        <div className={styles.left}>
          <Sidebar ></Sidebar>
        </div>
        <div className={styles.center}>
          <CanvasContent actionRef={actionRef}></CanvasContent>
        </div>
        <div className={styles.right}>
          <AttrConfig ></AttrConfig>
        </div>
      </div>
    </TemplateContext.Provider>
  </PageHeaderWrapper>
}

左侧菜单

左侧导航栏主要是提供组件 实现拖拽 并存储组件参数

const TemplateSidebar: React.FC<{}> = () => {

  const [controlList, setControlList] = useState<controlType[]>([]) //控件列表

  const onDragStart = (evt: React.DragEvent, item: any) => { // 拖拽事件
    evt.dataTransfer.setData('application/template', JSON.stringify(item));
    evt.dataTransfer.effectAllowed = 'move';
  }
  const getControlList = useCallback(() => {
    return controlList.map((item: controlType, index) => <Col span={12} key={index}>
      <div className={styles.control} onDragStart={(event) => onDragStart(event, item)} draggable>
        <span className={`icon iconfont ${item.iconName}`}></span>{item.contentName}
      </div>
    </Col>)
  },[controlList])

  useEffect(() => {
    setControlList([{
      contentName: '文本',
      iconName: 'icon-ziti',
      contentType: 1,
      contentTypeName: 'text'
    }, {
      contentName: '文本域',
      iconName: 'icon-ziti',
      contentType: 2,
      contentTypeName: 'textarea'
    },
  
    {
      contentName: '选择(单选)',
      iconName: 'icon-ziti',
      contentType: 5,
      contentTypeName: 'select',
      templateOptionList: []
    },
    {
      contentName: '日期组件',
      iconName: 'icon-ziti',
      contentType: 9,
      contentTypeName: 'contact_multiple',
    },
    ])
  }, []);
  return <div className={styles.templateSidebar}>
    <div className={styles.title}>
      组件库
    </div>
    <div className={styles.typeName}>
      基础组件
    </div>
    <Row className={styles.controlMain}>
      {
        getControlList && getControlList()
      }
    </Row>
  </div>
}
export default TemplateSidebar

中间画布

中间内容区域 分了两个部分 一个是画布用于接收拖拽的组件 一个负责展示拖拽的组件并实现可视化排序

画布组件

const CanvasContent: React.FC<{ actionRef: any }> = (props) => {
  const { actionRef } = props
  const [controlDataList, setControlDataList] = useState<controlDataType[]>([]) // 组件数据

  const childRef = useRef(null)
  if (actionRef) {
    useImperativeHandle(actionRef, () => ({ // 联动设置当前选中的组件参数
      setControlIndexVal: (val: controlDataType) => {
        const currentIndex = childRef.current.getCurrentIndex()
        setControlVal(val, currentIndex)
      },
    }))
  }
  const onDrop = (event: React.DragEvent) => {  // 画布接收拖拽放开后的参数 分为左侧导航栏拖拽的 和内容区域拖拽排序的
    event.preventDefault();
    if (event.dataTransfer.getData('application/template')) {//导航栏拖拽
      const itemTemplate = JSON.parse(event.dataTransfer.getData('application/template'));
      const { hoverIndex } = childRef.current.getHoverIndex() //获取鼠标当前滑动到的位置
      let arr = controlDataList
      let objCount = {}
      arr.forEach((item) => {
        if (objCount[item.contentTypeName]) {
          objCount[item.contentTypeName]++;
        } else {
          objCount[item.contentTypeName] = 1
        }
      })
      const count = (objCount[itemTemplate.contentTypeName] || 0) + 1 //生成KEY值
      let template = { ...itemTemplate, filedMapping: itemTemplate.contentTypeName + '_' + count, isEnable: 1, isRequired: 1, message: '', prompt: '' }
      if (hoverIndex > -1) { //插入数据
        arr.splice(hoverIndex, 0, template);
      } else {
        arr.push(template)
      }
      setControlDataList([...arr])
      childRef.current.hoverEnd() //重置滑动下标
    } else if (event.dataTransfer.getData('application/moveControl')) {
      let arr = controlDataList
      const { hoverIndex, currentIndex } = childRef.current.getHoverIndex()// 获取鼠标当前滑动到的位置 和拖拽起始位置
      if (currentIndex === (hoverIndex - 1)) {
        return
      }
      if (hoverIndex === -1 || currentIndex === -1) {
        childRef.current.hoverEnd()
        return
      } else if (hoverIndex === arr.length) { //此时是移动到最后位置的处理
        const temp = arr[currentIndex];
        arr.splice(currentIndex, 1);
        arr.push(temp)
      } else {
        const temp = arr[currentIndex];//基于滑动位置插入 删除原有位置数据
        arr[currentIndex] = undefined;
        arr.splice(hoverIndex, 0, temp);
        arr = arr.filter((item) => item)
        setControlDataList([...arr])
      }
      childRef.current.hoverEnd()
    }
  }
  const onDragOver = useCallback((event) => { // 拖动中事件
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
  }, []);

  const setControlVal = (val: controlDataType, index: number) => { //根据下标设置参数
    let arr = deepCopy(controlDataList)
    arr[index] = { ...val }
    setControlDataList([...arr])
  }
  const onCopyAdd = (itemData: controlDataType, index: number) => { // 复制组件
    let arr = deepCopy(controlDataList)
    let objCount = {}
    arr.forEach((item: controlDataType) => {
      if (objCount[item.contentTypeName]) {
        objCount[item.contentTypeName]++;
      } else {
        objCount[item.contentTypeName] = 1
      }
    })
    const count = (objCount[itemData.contentTypeName] || 0) + 1
    arr.splice(index, 0, { ...deepCopy(itemData), filedMapping: itemData.contentTypeName + '_' + count });
    setControlDataList([...arr])
  }
  const onDellData = (itemData: controlDataType, index: number) => { // 删除组件
    let arr = controlDataList
    arr.splice(index, 1);
    childRef.current.hoverEnd()
    setControlDataList([...arr])
  }
  return <div className={styles.canvasContent} onDragOver={(event) => onDragOver(event)} onDrop={(event) => onDrop(event)} >
    <FormTemplate actionRef={childRef} onDellData={onDellData} onCopyAdd={onCopyAdd} dataArr={controlDataList} setControlVal={setControlVal} ></FormTemplate>
  </div>
}

模板渲染组件 该组件稍微复杂点的功能就是拖拽插入排序了 核心就是记录插入的下标 目前设计的是向上插入 所以底部需要加个容器是的可以拖拽至底部

const FormTemplate: React.FC<FormTemplateProps> = (props) => {
  const [templateForm] = Form.useForm()
  const [checkFiledMapping, setCheckFiledMapping] = useState<string>('');
  const [activateIndex, setActivateIndex] = useState<number>(-1);
  const [hoverIndex, setHoverIndex] = useState<number>(-1);

  const { currentControl, setCurrentControl, } = useContext(TemplateContext)

  const { dataArr, actionRef, onCopyAdd, onDellData } = props
  const startRef = useRef(null);
  if (actionRef) {
    useImperativeHandle(actionRef, () => ({
      getHoverIndex: () => { // hoverIndex:获取鼠标拖拽时当前下标 currentIndex:组件拖拽起始下标
        return { hoverIndex, currentIndex: startRef.current };
      },
      getCurrentIndex: () => { // 获取当前选中下标
        return activateIndex;
      },
      hoverEnd: () => { // 重置拖拽时下标
        setHoverIndex(-1)
      }
    }))
  }
  const onClickItem = (event: React.BaseSyntheticEvent, controlItem: controlDataType, index: number) => {
    // 选中组件事件
    event.stopPropagation(); //防止冒泡触发父组件点击事件
    setCheckFiledMapping(controlItem.filedMapping)
    setCurrentControl({ ...deepCopy(controlItem) })
    setActivateIndex(index)
  }
  const onDragStart = (evt: React.DragEvent, item: controlDataType, index: number) => {
    // 组件拖拽初始事件 记录初始下标
    startRef.current = index;
    evt.dataTransfer.setData('application/moveControl', JSON.stringify(item));
    evt.dataTransfer.effectAllowed = 'move';
  }
  const onDragEnd = (evt: React.DragEvent, index: number) => {
    evt.preventDefault();
  }
  const onDragEnter = (evt: React.DragEvent, index: number) => {
    // 组件拖拽时事件 记录插入下标
    evt.preventDefault();
    if (startRef.current === index) {
      setHoverIndex(-1)
      return;
    }
    setHoverIndex(index)
  }

  const onCopy = (event: React.BaseSyntheticEvent, item: controlDataType, index: number) => {
    event.stopPropagation();
    if (onCopyAdd) {
      onCopyAdd(item, index)
    }
  }
  const onDell = (event: React.BaseSyntheticEvent, item: controlDataType, index: number) => {
    event.stopPropagation();
    if (onDellData) {
      onDellData(item, index)
    }
  }
  const getFormItem = () => {
    return dataArr.map((item: controlDataType, index: number) => {
      //activate:当前选中的元素 noEnable:是否禁用 thingHover:拖拽时的插入提示
      return (
        <div className={classNames(styles.thing, checkFiledMapping === item.filedMapping ? styles.activate : ''
          , item.isEnable === 1 ? '' : styles.noEnable, hoverIndex === index ? styles.thingHover : '')}
          onDragEnter={(event) => onDragEnter(event, index)}
          onDragEnd={(event) => onDragEnd(event, index)}
          onDragStart={(event) => onDragStart(event, item, index)} draggable
          onClick={(event) => onClickItem(event, item, index)} key={item.filedMapping}>
          <div className={styles.handleCell}>
            <span className={styles.copy} onClick={(event) => onCopy(event, item, index)}>复制</span>

            <span className={styles.dell} onClick={(event) => onDell(event, item, index)}>删除</span>
          </div>
          <Form.Item
            label={item.contentName + ':'}
            name={item.filedMapping}
            rules={[{ required: item.isRequired === 1, message: item.message }]}
          >
            {getFormControl(item)}
          </Form.Item>
        </div>

      )
    })
  }

  const getFormControl = useCallback((item: controlDataType) => {
    switch (item.contentType) {
      case 1:
        return (
          <Input
            readOnly
            placeholder={item.prompt}
            autoComplete="off"
          />
        )
      case 2:
        return (
          <Input.TextArea
            readOnly
            autoSize={{ minRows: 2, maxRows: 18 }}
            showCount={true}
            placeholder={item.prompt}
          />
        )

      case 5:
        return (
          <Select
            open={false}
            options={[]}
            placeholder={item.prompt}
            getPopupContainer={(triggerNode) => triggerNode.parentNode}
          />
        )
      case 9:
        return <DatePicker disabled placeholder={item.prompt} 
      default:
        return null
    }
  }, [])

  const formTemplateClick = () => {
    if (checkFiledMapping) {
      setCheckFiledMapping('')
      setCurrentControl({})
      setActivateIndex(-1)
    }
  }

  return <div className={styles.formTemplate} onClick={() => formTemplateClick()}>
    <Form layout="vertical" colon={false} form={templateForm}>
      <ConfigProvider locale={locale}>
        {getFormItem && getFormItem()}
        {
          //增加一个底部容器 因为都是向上插入 导致无法增加到底部 所以加个空白容器 使其能够拖拽至底部
          dataArr.length > 0 && <div className={classNames(styles.bottomItem, hoverIndex === dataArr.length ? styles.thingHover : '')} onDragEnter={(event) => onDragEnter(event, dataArr.length)}>
          </div>
        }
      </ConfigProvider>
    </Form>

  </div>
}

右侧配置栏

这个就没什么难点了 基本就是form表单跟画布组件数据联动 后续需要拓展配置参数可在该组件实现

const AttrConfig: React.FC<AttrConfigProps> = (props) => {
  const [configForm] = Form.useForm()
  const { currentControl, setControlVal } = useContext(TemplateContext)

  const [isShow, setIsShow] = useState<boolean>(false);//是否展示

  const onSaveConfig = () => {
    configForm.validateFields().then((values) => {
      const data = deepCopy({ ...currentControl, ...values })
      setControlVal({ ...data }) //保存进行数据联动
    })
  }
  const getExtraItem = () => {
    const { contentType } = currentControl
    if (contentType === 5) { //根据类型控制展示设置项
      return <Form.Item
        label="下拉选项"
        name="templateOptionList"
        initialValue={[]}
        key={currentControl.filedMapping}
        rules={[
          {
            required: true,
            message: '请输入要新增的选项',
            validator(rule, value) {
              if (value) {
                return Promise.resolve();
              }
              return Promise.reject();
            }
          }
        ]}
      >
        <Select mode="tags" labelInValue options={[]} />
      </Form.Item>
    } else {
      return null
    }
  }
   const getFormItem = useCallback(() => {
    if (isShow) {
      return <>
        <Form.Item name="contentName" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
          <Input />
        </Form.Item>
        <Form.Item label="标签类型" >
          <span>
            {typeMap[currentControl.contentType]}
          </span>
        </Form.Item>
        {getExtraItem && getExtraItem()}


        <Form.Item
          label="是否必填"
          name="isRequired"
          valuePropName="checked"
          normalize={(value: any) => Number(value)}
        >
          <Switch
            checkedChildren={isRequiredMap[1]}
            unCheckedChildren={isRequiredMap[0]}
          />
        </Form.Item>

        {currentControl.contentType !== 15 && <Form.Item name="prompt" label="占位提示" >
          <Input />
        </Form.Item>}
        <Form.Item
          label="是否启用"
          name="isEnable"
          valuePropName="checked"
          normalize={(value: any) => Number(value)}
        >
          <Switch
            checkedChildren={isEnableMap[1]}
            unCheckedChildren={isEnableMap[0]}
          />
        </Form.Item>

        <Row justify="center">
          <Button type="primary" onClick={() => onSaveConfig()}>保存</Button>
        </Row>
      </>
    } else {
      return null
    }
  }, [isShow, currentControl])

  useEffect(() => {
    if (currentControl && Object.keys(currentControl).length) {
      //切换组件时更新设置项参数
      setIsShow(true)
      configForm.setFieldsValue({ ...currentControl })
    } else {
      setIsShow(false)
    }
  }, [currentControl]);

  return <div className={styles.attrConfig}>
    <Form form={configForm} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} layout="horizontal" name="nest-messages" >
      {getFormItem && getFormItem()}
    </Form>
  </div >
}