基于 Braft Editor 实现自定义输出HTML内容

5,071 阅读8分钟

在实际业务场景中,需要对文本内容进行富文本编辑。

braft-editor是一个美观易用的React富文本编辑器,这个编辑器开箱即用,不用重复造轮子。这个编辑器是基于facebook的draft-js开发的,draft-js可以抽象理解成一个webpack,是要做各种配置才能使用。

下图展示的是Braft Editor编辑器:

其输出内容为:

<p>你好,<strong>世界!</strong></p><p></p>

标准用法示例源码:

import 'braft-editor/dist/index.css'
import React from 'react'
import BraftEditor from 'braft-editor'

export default class BasicDemo extends React.Component {

  state = {
    editorState: BraftEditor.createEditorState('<p>Hello <b>World!</b></p>'), // 设置编辑器初始内容
    outputHTML: '<p></p>'
  }

  componentDidMount () {
    this.isLivinig = true
    // 3秒后更改编辑器内容
    setTimeout(this.setEditorContentAsync, 3000)
  }

  componentWillUnmount () {
    this.isLivinig = false
  }

  handleChange = (editorState) => {
    this.setState({
      editorState: editorState,
      outputHTML: editorState.toHTML()
    })
  }

  setEditorContentAsync = () => {
    this.isLivinig && this.setState({
      editorState: BraftEditor.createEditorState('<p>你好,<b>世界!</b><p>')
    })
  }

  render () {

    const { editorState, outputHTML } = this.state

    return (
      <div>
        <div className="editor-wrapper">
          <BraftEditor
            value={editorState}
            onChange={this.handleChange}
          />
        </div>
        <h5>输出内容</h5>
        <div className="output-content">{outputHTML}</div>
      </div>
    )

  }

}

上面的代码展示的是 Braft Editor 的标准用法,其输出的HTML内容是编辑器默认输出的HTML,并不适合我们实际业务场景。因此需要使用 Braft Editor 自定义编辑器输出的HTML。

Braft Editor自定义Block的数据结构

这个Block可以这样理解,在编辑器中这个Block是一个带有行为的React组件,如果是单纯显示,这个Block是Html字符串,至于是否需要带有行为,这个需要看具体项目而定。在本次业务场景中,不需要用到Block,而是自定义block的html导出函数,用于将不同的block转换成不同的html内容。

下面让我们来看一下 block 的数据结构:

1、type 类型为 unstyled 的 block 数据结构

从 block 的数据结构可以看出,如果对文本设置了粗体、颜色、下划线等基础样式,Braft Editor 会把你设置的基础样式放到 inlineStyleRanges 数组里,数据结构里的 text 为你设置样式的文本,data属性存放的是对文本设置了对齐方式时的样式

2、type 类型为 atomic 的block 数据结构

上图为 atomic 类型,即媒体类型的block数据结构,当你对编辑器中的媒体设置了对齐方式时,比如对图片设置了右对齐,Braft Editor 会把你设置的样式放到 block 数据结构的 data 属性里。如上图所示,alignment 属性是对齐的方式,即右对齐;nodeAttributes 属性存放的是css样式类名。

有时候我们需要获取设置了样式的内容进行处理,但是在 atomic 类型的 block 数据结构中,无法获取到设置了样式的内容,那么怎样才能获取到呢?

自定义Block输出HTML内容

下面函数blockExportFn 在开发者调用this.state.editorState.toHTML() 会触发。

// 自定义block输出转换器,用于将不同的block转换成不同的html内容,通常与blockImportFn中定义的输入转换规则相对应
const blockExportFn = (contentState: any, block: any) => {

  if (block.type === "atomic") {
    // 对于图片操作的自定义输出HTML
    if (block.entityRanges && block.entityRanges.length > 0) {
      // 根据 当前 block 的 key 获取 当前的内容区块 contentBlock 对象
      const contentBlock = contentState.getBlockForKey(block.key);
      // 获取 contentBlock 中给定偏移量的实体健值
      const entityKey = contentBlock.getEntityAt(0);
      // 获取指定健值的 DraftEntityInstance 实例
      const entityInstance = contentState.getEntity(entityKey);

      if (entityInstance) {
        const instanceData = entityInstance.getData();
        if (entityInstance.type === "IMAGE") {
          let textAlignClass = null;

          if (block.data && block.data.alignment) {
            const alignment = block.data.alignment;
            textAlignClass = setTextAlignClass(alignment)
          }

          const imgPlaceHolder = instanceData.url.match(/#img:\d+/)?.[0].replace('#', '');
          if (textAlignClass) {
            // 输出内容为设置了对齐方式的图片占位符
            return `<p class="${textAlignClass}"><!--{${imgPlaceHolder}}--></p>`;
          } else {
            // 输出内容为未设置对齐方式的图片占位符
            return `<!--{${imgPlaceHolder}}-->`
          }
        }
      }
    }
  } else if (block.type === "unstyled") {
    // 对于基础样式 字号、字体颜色、加粗、斜体、下划线的自定义输出HTML
    let textArray = block.text.split("");
    if (block.inlineStyleRanges && block.inlineStyleRanges.length > 0) {
      let appendStyle = "";
      for (const inlineStyle of block.inlineStyleRanges) {
        if (inlineStyle.style === "BOLD") {
          appendStyle = `<span class="b">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style === "UNDERLINE") {
          appendStyle = `<span class="i">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style === "ITALIC") {
          appendStyle = `<span class="u">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style === "STRIKETHROUGH") {
          appendStyle = `<span class="d">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style.startsWith("COLOR-")) {
          appendStyle = `<span style="color:${inlineStyle.style.replace(
            "COLOR-",
            "#"
          )};">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style.startsWith("FONTSIZE-")) {
          appendStyle = `<span style="font-size:${inlineStyle.style.replace(
            "FONTSIZE-",
            ""
          )};">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style.startsWith("BGCOLOR-")) {
          appendStyle = `<span style="background-color:${inlineStyle.style.replace(
            "BGCOLOR-",
            "#"
          )};">${textArray[inlineStyle.offset]}`;
        }
        textArray[inlineStyle.offset] = appendStyle;
        textArray[inlineStyle.offset + inlineStyle.length - 1] = `${
          textArray[inlineStyle.offset + inlineStyle.length - 1]
          }</span>`;
      }
    }

    // 文本对齐方式的自定义输出HTML
    if (block.data && block.data.textAlign) {
      let textAlignClass = null;
      const currTextAlign = block.data.textAlign;
      textAlignClass = setTextAlignClass(currTextAlign)

      if (textAlignClass) {
        return `<p class="${textAlignClass}">${textArray.join("")}</p>`;
      } else {
        return `<p>${textArray.join("")}</p>`;
      }
    } else {
      return `<p>${textArray.join("")}</p>`;
    }
  }
};

从上述代码中,我们可以看到,blockExportFn 函数接受两个参数,contentState 对象 和 block 对象。从block 对象中我们可以获取到对文本内容设置样式后的相关样式。

上文中我们提到,在 atomic 类型的 block 数据结构中,无法获取到设置了样式的内容,那么怎样才能获取到呢?在 blockExportFn 函数的第一个参数 contentState 对象中就可以获取到。

// 根据 当前 block 的 key 获取 当前的内容区块 contentBlock 对象
const contentBlock = contentState.getBlockForKey(block.key);
// 获取 contentBlock 中给定偏移量的实体健值
const entityKey = contentBlock.getEntityAt(0);
// 获取指定健值的 DraftEntityInstance 实例
const entityInstance = contentState.getEntity(entityKey);

if (entityInstance) {
  //  DraftEntityInstance 实例 数据
  const instanceData = entityInstance.getData();
  // 从 DraftEntityInstance 实例 中获取图片的链接
  const imgPlaceHolder = instanceData.url.match(/#img:\d+/)?.[0].replace('#', '');
}

最后附上该项目的全部代码:

组件目录:

index.tsx 文件:

import 'braft-editor/dist/index.css'
import 'braft-extensions/dist/color-picker.css'
import React from 'react'
import { Form } from 'antd';
import BraftEditor, { BraftEditorProps, ExtendControlType, ControlType, EditorState, ImageControlType } from 'braft-editor'
import { ContentUtils } from 'braft-utils';
import ColorPicker from 'braft-extensions/dist/color-picker'
import './style.less';
import InsertImagesModal from './insertImageModal'
import { blockExportFn, convert, input } from './convert'

BraftEditor.use(ColorPicker({
  includeEditors: ['editor-with-color-picker'],
  theme: 'light' // 支持dark和light两种主题,默认为dark
}))

// 编辑器控件
const controls: ControlType[] = [
  "undo",
  "redo",
  "separator",
  "font-size",
  "text-color",
  "bold",
  "italic",
  "underline",
  "strike-through",
  "separator",
  "text-align",
  "separator",
  "remove-styles",
  'fullscreen'
]

export interface IDefaultProps extends BraftEditorProps {
  htmlContent: string;
  imagesResource: Array<any>; // 插入的图片资源
  onChange?: () => void;
  getChildInstance: (instance: any) => void;
  braftEditorOnBlur?: (editorState: EditorState) => void; // 富文本编辑器失去焦点后的处理函数
  insertImgAfterOnChange?: (editorState: EditorState) => void; // 插入图片后的处理函数
  [propsName: string]: any;
}
@(Form as any).create()
class RichTextEditor extends React.PureComponent<IDefaultProps> {
  constructor(props: any) {
    super(props)
    // 把当前组件的 this 暴露给 父组件
    const { getChildInstance } = props;
    if (typeof getChildInstance === 'function') {
      getChildInstance(this); // 在这里把this暴露给`parentComponent`
    }
  }

  state = {
    modalVisible: false,
    editorState: BraftEditor.createEditorState(convert(this.props.htmlContent, input))
  }

  editorInstance: EditorState | undefined

  hooks = {
    "set-image-alignment": (alignment: string) => {
      this.editorInstance.requestFocus();
      return alignment;
    }
  }

  componentDidMount() {
    // 获取媒体库实例
    // this.braftFinder = this.editorInstance.getFinderInstance()
    // console.log('=====braftFinder======', this.editorInstance)
  }

  updateState = (state: any) => {
    this.setState({
      ...state
    })
  }

  insertImageItem = () => {
    this.setState({
      modalVisible: true
    })
  }
  insertImage = ({ imgUrl }: { imgUrl: string }) => {
    // 使用ContentUtils.insertMedias来插入媒体到editorState
    const editorState = ContentUtils.insertMedias(this.state.editorState, [
      {
        type: 'IMAGE',
        url: imgUrl
      }
    ])

    // 更新插入媒体后的editorState
    this.setState({ editorState })
  }

  handleChange = (editorState: EditorState) => {
    // console.log('======editorState======', editorState.toHTML())
    this.setState({ editorState })
  }

  render() {

    const imageControls: ImageControlType[] = [
      'align-left', // 设置图片居左
      'align-center', // 设置图片居中
      'align-right', // 设置图片居右
      'remove' // 删除图片
    ]
    // 编辑器扩展控件
    const extendControls: ExtendControlType[] = [
      {
        key: 'insert-image',
        type: 'button',
        // title: '插入图片',
        className: '',
        html: null,
        // text: <Icon type="file-image" style={{ fontSize: '18px' }} />,
        text: '插入图片',
        onClick: this.insertImageItem
      }
    ]

    return (
      <>
        <BraftEditor
          id="editor-with-color-picker"
          className="editor-wrapper"
          ref={(instance: any) => this.editorInstance = instance}
          controls={controls}
          value={this.state.editorState}
          onChange={this.handleChange}
          converts={{ blockExportFn: (contentState: any, block: any) => blockExportFn(contentState, block, this.props.imagesResource) }}
          extendControls={extendControls}
          // media={{items: mediaItems}}
          imageControls={imageControls}
          // 操作编辑器内的内容后,在最外层组件点击提交按钮,会无法触发富文本编辑器的 onBlur 事件,导致提交时无法获取最新编辑后的文本内容
          // onBlur={() => {
          //   console.log('=====触发===onBlur====事件=======')
          //   this.props.braftEditorOnBlur(this.state.editorState)
          // }}
          hooks={this.hooks}
        />
        <InsertImagesModal
          modalVisible={this.state.modalVisible}
          imagesResource={this.props.imagesResource}
          updateState={this.updateState}
          insertImage={this.insertImage}
        />
      </>
    )

  }
}

export default RichTextEditor;

insertImageModal.tsx 文件:

import React from 'react'
import { Form, Modal, Input, message } from 'antd';

export interface IDefaultProps {
  imagesResource: Array<any>; // 插入的图片资源
  updateState: (state: any) => void;
  insertImage: ({ imgUrl }: { imgUrl: string }) => void;
  [propsName: string]: any;
}
// @(Form as any).create()
class InsertImage extends React.PureComponent<IDefaultProps> {

  modalOnCancel = () => {
    this.props.updateState({
      modalVisible: false
    })
  }

  modalOnOk = () => {
    const imgUrl = this.props.form.getFieldValue('url')
    message.destroy();
    if (!imgUrl) {
      message.warn('请输入图片地址')
      return
    }

    if (!imgUrl.startsWith('http')) {
      message.warn('必须插入已上传的图片!')
      return
    }

    const urlIsInImagesResource = this.props?.imagesResource?.find((item: any) => item.url === imgUrl.replace(/#img:\d+/, ''))
    if (!urlIsInImagesResource) {
      message.warn('必须插入已上传的图片!')
      return
    }
    this.props.updateState({ modalVisible: false })
    this.props.insertImage({ imgUrl })
  }

  render() {

    return (
      <Modal
        title="输入已上传的图片地址:"
        okText="确认"
        onOk={this.modalOnOk}
        onCancel={this.modalOnCancel}
        visible={this.props.modalVisible}
        destroyOnClose
        maskClosable={false}
        mask={false}
        zIndex={999999}
      >
        {this.props.form.getFieldDecorator('url', {
          initialValue: '',
        })(<Input autoComplete="off" autoFocus placeholder="输入已上传的图片地址" />)}
      </Modal>
    )

  }
}

const InsertImageModal: any = Form.create()(InsertImage);
export default InsertImageModal;

convert.ts 文件:

const setTextAlignClass = (alignment: string): string => {
  let textAlignClass = 'tl'
  if (alignment === "center") {
    textAlignClass = "tc";
  } else if (alignment === "right") {
    textAlignClass = "tr";
  } else if (alignment === "left") {
    textAlignClass = "tl";
  }
  return textAlignClass
}

export function input(node: any) {
  if (node.nodeName === 'IMG') {
    const parentNode = node.parentNode

    if (parentNode.classList.contains('tc')) {
      parentNode.style.textAlign = "center";
    } else if (parentNode.classList.contains('tl')) {
      parentNode.style.textAlign = "left";
    } else if (parentNode.classList.contains('tr')) {
      parentNode.style.textAlign = "right";
    }
    parentNode.classList = ['media-wrap', 'image-wrap'].join(" ")
  }
  if (node.classList.contains("i")) {
    node.style.fontStyle = "italic";
  }
  if (node.classList.contains("u")) {
    node.style.textDecoration = "underline";
  }

  if (node.classList.contains("b")) {
    node.style.fontWeight = "bold";
  }

  if (node.classList.contains("d")) {
    node.style.textDecoration = "line-through";
  }

  if (node.classList.contains("tc")) {
    node.style.textAlign = "center";
  }

  if (node.classList.contains("tr")) {
    node.style.textAlign = "right";
  }

  if (node.classList.contains("tl")) {
    node.style.textAlign = "left";
  }
}

export function convert(richData: any, nodeCtrlFn: any) {
  const wrap = document.createElement('div');
  wrap.innerHTML = richData;

  function recurChilds(nodes: any, fn: any) {
    if (nodes) {
      for (const childNode of nodes) {
        fn(childNode);
        recurChilds(childNode.children, fn);
      }
    }
  }

  recurChilds(wrap.children, nodeCtrlFn);
  return wrap.innerHTML;
}

// 正则替换
export const classConvertToStyle = (htmlContent: string) => {
  htmlContent = htmlContent.replace(/class\s*=\s*"\s*i\s*"/g, 'style="text-decoration: underline;"')
    .replace(/class\s*=\s*"\s*b\s*"/g, 'style="font-weight: bold;"')
    .replace(/class\s*=\s*"\s*u\s*"/g, 'style="font-style: italic;"')
    .replace(/class\s*=\s*"\s*d\s*"/g, 'style="text-decoration: line-through;"')
    .replace(/class\s*=\s*"\s*tc\s*"/g, 'style="text-align: center;"')
    .replace(/class\s*=\s*"\s*tr\s*"/g, 'style="text-align: right;"')
    .replace(/class\s*=\s*"\s*tl\s*"/g, 'style="text-align: left;"');

  return htmlContent
}

// 自定义block输出转换器,用于将不同的block转换成不同的html内容,通常与blockImportFn中定义的输入转换规则相对应
export const blockExportFn = (contentState: any, block: any, imagesResource: any) => {

  if (block.type === "atomic") {
    if (block.entityRanges && block.entityRanges.length > 0) {
      const contentBlock = contentState.getBlockForKey(block.key);
      const entityKey = contentBlock.getEntityAt(0);
      const entityInstance = contentState.getEntity(entityKey);

      if (entityInstance) {
        const instanceData = entityInstance.getData();
        if (entityInstance.type === "IMAGE") {
          let textAlignClass = null;

          if (block.data && block.data.alignment) {
            const alignment = block.data.alignment;
            textAlignClass = setTextAlignClass(alignment)
          }
          // console.log('=======textAlignClass====', textAlignClass)
          // const imgPlaceHolder = instanceData.url.match(/#img:\d+/)?.[0].replace('#', '');
          const index = imagesResource.findIndex((item: any) => item.url === instanceData.url)

          if (textAlignClass) {
            // return `<p class="${textAlignClass}"><img src="${
            //   instanceData.url
            //   }" /></p>`;
            return `<p class="${textAlignClass}"><!--{img:${index}}--></p>`;
          } else {
            // return `<img src="${instanceData.url}" />`;
            return `<!--{img:${index}}-->`
          }
        }
      }
    }
  } else if (block.type === "unstyled") {
    let textArray = block.text.split("");
    if (block.inlineStyleRanges && block.inlineStyleRanges.length > 0) {
      let appendStyle = "";
      for (const inlineStyle of block.inlineStyleRanges) {
        if (inlineStyle.style === "BOLD") {
          appendStyle = `<span class="b">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style === "UNDERLINE") {
          appendStyle = `<span class="u">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style === "ITALIC") {
          appendStyle = `<span class="i">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style === "STRIKETHROUGH") {
          appendStyle = `<span class="d">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style.startsWith("COLOR-")) {
          appendStyle = `<span style="color:${inlineStyle.style.replace(
            "COLOR-",
            "#"
          )};">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style.startsWith("FONTSIZE-")) {
          appendStyle = `<span style="font-size:${inlineStyle.style.replace(
            "FONTSIZE-",
            ""
          )};">${textArray[inlineStyle.offset]}`;
        } else if (inlineStyle.style.startsWith("BGCOLOR-")) {
          appendStyle = `<span style="background-color:${inlineStyle.style.replace(
            "BGCOLOR-",
            "#"
          )};">${textArray[inlineStyle.offset]}`;
        }
        textArray[inlineStyle.offset] = appendStyle;
        textArray[inlineStyle.offset + inlineStyle.length - 1] = `${
          textArray[inlineStyle.offset + inlineStyle.length - 1]
          }</span>`;
      }
    }

    if (block.data && block.data.textAlign) {
      let textAlignClass = null;
      const currTextAlign = block.data.textAlign;
      textAlignClass = setTextAlignClass(currTextAlign)

      if (textAlignClass) {
        return `<p class="${textAlignClass}">${textArray.join("")}</p>`;
      } else {
        return `<p>${textArray.join("")}</p>`;
      }
    } else {
      return `<p>${textArray.join("")}</p>`;
    }
  }
};

style.less 文件:

.editor-wrapper {
    border: 1px solid #d9d9d9;
}

:global {
  .ant-message {
    z-index: 999999;
  }
}

二级父组件代码:

import React from 'react';
import { formItemLayout, FormItem } from '../components/base';
import RichTextEditor from '@/components/BraftEditor';
import { Spin } from 'antd';
import { imgPlaceholderConvertToImgLabel } from "@/utils/richTextConvertTools"

export interface ISetting {
  field: string;
  label: string;
  disabled: boolean;
  placeholder: string;
  required: boolean;
  whitespace: boolean;
  errorMsg: string;
  maxLength?: number;
  autoSize?: boolean | object;
  isRender?: boolean;
  imagesResourceField: string;
}

export interface IProps {
  setting: ISetting;
  [propsName: string]: any;
}
export interface IState {
}

// 标签+文本框
export class LabelRichTextEditor extends React.PureComponent<IProps, IState> {

  constructor(props: IProps) {
    super(props);
    const { getChildInstance } = props;
    if (typeof getChildInstance === 'function') {
      // 把 RichTextEditor 组件的实例暴露给父组件
      // getChildInstance(this.getChildInstanceCb);
    }
  }

  getChildInstanceCb: any;

  setting: ISetting = {
    field: "",  // 绑定数据结构体的字段名称
    label: "",  // 标签名称
    disabled: false,   // 是否为不可编辑状态:true - 不可编辑 , false - 可以编辑
    placeholder: '',   // 文本框输入提示
    required: false,   // 该项是否为必填项:true - 必填 , false - 非必填
    whitespace: false,   // 是否不允许为空值:true - 是 , false - 否
    errorMsg: '',   // 检验出错的提示
    isRender: true, // 是否渲染富文本编辑器组件
    imagesResourceField: "images", //插入的图片资源, 默认字段为 images
  };

  componentDidMount() {
    let { setting } = this;
    setting = Object.assign(setting, this.props.setting);
    if (!setting.label) console.error("字段 " + setting.field + " 未配置 label 属性");
    this.props.form.setFieldsValue({
      [setting.field]: this.props.formData[setting.field]
    })
  }

  // 富文本编辑器 onChange 事件处理函数 onBlur 事件处理函数
  // braftEditorOnBlur = (editorState: any) => {
  //   let { setting } = this;
  //   setting = Object.assign(setting, this.props.setting);
  //   this.props.form.setFieldsValue({
  //     [setting.field]: editorState.toHTML()
  //   })
  // }

  // 自定义控件插入图片后的处理函数
  // insertImgAfterOnChange = (editorState: any) => this.braftEditorOnBlur(editorState)

  render(): React.ReactNode {
    const {
      form: {
        getFieldDecorator
      },
      formData = {},
      formItemLayout: propLayout = {},
      richTextEditor,
    } = this.props;

    // 把 RichTextEditor 组件的实例暴露给父组件
    this.props.getChildInstance(this.getChildInstanceCb)

    let { setting } = this;
    setting = Object.assign(setting, this.props.setting);
    if (!setting.field || !setting.label) return "";

    const fieldOptions: any = {}

    const imagesResource = this.props.form.getFieldValue(setting.imagesResourceField)
    const htmlContent = richTextEditor
      ? imgPlaceholderConvertToImgLabel(richTextEditor.richTextEditorHtml, imagesResource)
      : imgPlaceholderConvertToImgLabel(formData[setting.field], imagesResource)

    const isRender = richTextEditor ? richTextEditor.richTextEditorRender : setting.isRender

    let rules: any[] = [];
    let rule: any = {};
    if (setting.required) rule.required = setting.required;
    rule.whitespace = setting.whitespace;
    if (setting.errorMsg) rule.message = setting.errorMsg;
    rules.push(rule);

    if (rules.length) fieldOptions.rules = rules;

    const newLayout = Object.assign(formItemLayout, propLayout);
    return <FormItem label={setting.label} className="col" {...newLayout}>
      {getFieldDecorator(setting.field, fieldOptions)(
        isRender ?
          <RichTextEditor
            htmlContent={htmlContent}
            imagesResource={imagesResource}
            getChildInstance={(childCb: Function) => this.getChildInstanceCb = childCb}
          />
          : <Spin spinning={true} />
      )}
    </FormItem>
  }
}

一级父组件代码:

import React from 'react';
import { Form, Button } from 'antd';
import { DEEP_MERGE_TEXT_MARK } from '@/utils/constant'
import Field from './fields';
import utils from '@/utils/utils';
import { COMPONENT_NAME } from '@/utils/constant'
import { imgPlaceholderConvertToImgLabel } from '@/utils/richTextConvertTools'

import './style.less';

export interface ISettingItem {
  field: string;
  dictGroupField?: string;
  label?: string;
  component: string;
  mode?: string;
  sList: any[];  // 下拉列表框列表项
  required?: boolean;
}

export interface IEditFormProps {
  form: any;  // Antd Form表单体对象
  setting: {
    fixedField: any[];
    editFormSetting: any[];
  }
  editType: string;
  recordData: object;
  onSubmit: Function;
}

const initialState: any = {
  formData: {}
}
type TState = Readonly<typeof initialState>

class EditForm extends React.PureComponent<IEditFormProps, TState> {
  readonly state: TState = initialState;
  getChildInstance: any;

  componentDidMount() {
    const {
      setting: {
        editFormSetting = []
      },
      recordData = {},
    } = this.props;

    // 源数据结构转换为表单结构
    const formData = {};
    editFormSetting.length && editFormSetting.map((item: any) => {
      if (!item.structureFiled) item.structureFiled = [item.field];
      let value = recordData || '';
      item.structureFiled.length && item.structureFiled.map((subItem: string) => {
        // 注: 来源数据节点的值为 0 时,同样需要设置表单对应项的值为 0
        value = value[subItem] || value[subItem] === 0 ? value[subItem] : '';
      })
      formData[item.field] = value;
    })

    this.setState({ formData });
  }

  // 提交保存
  onSubmit = (e: any) => {
    e.preventDefault();
    const {
      form,
      setting: {
        fixedField = [],
        editFormSetting = []
      },
      editType = 'edit',
      onSubmit
    } = this.props;

    form.validateFields((err: {}, values: any) => {
      if (err) return;

      // 清理 values 中的空对象、空数组
      Object.keys(values).map(item => {
        if ((!values[item] && values[item] !== 0) || (values[item] instanceof Array && values[item].length === 0)) {
          if (editType === 'edit') {
            values[item] = DEEP_MERGE_TEXT_MARK.DELETE;
          } else {
            delete values[item];
          }
        }
      });

      // 对富文本表单的字段值进行重新赋值(解决富文本编辑器有时无法触发失去焦点事件的问题
      const richTextEditorSetting = editFormSetting.find((item: any) => item.component === COMPONENT_NAME.LABEL_RICH_TEXT_EDITOR)
      // 通过获取到 富文本编辑器的实例,获取实例的 state,拿到 editorState
      values[richTextEditorSetting.field] = this.getChildInstance.state.editorState.toHTML()
      form.setFieldsValue({
        [richTextEditorSetting.field]: this.getChildInstance.state.editorState.toHTML()
      })

      //// TODO: 不符合条件的字段清理

      // 回溯表单数据结构
      const postData: any = {};
      editFormSetting.length && editFormSetting.map((item: any) => {
        const rFiled = item.structureFiled || [item.field];
        const isRewrite = item.changeMode === 'rewrite';
        if (isRewrite && values[item.field]?.toString() === '[object Object]') {
          values[item.field].deepMergeTextMark = DEEP_MERGE_TEXT_MARK.REWRITE;
        }

        if (
          !rFiled  // 数据结构路径钩子不存在
          || !values[item.field] && values[item.field] !== 0  // 指定的表单字段不存在
          || item.disabled   // 字段禁用,不允许改动
        ) return;
        let tmp1 = postData;
        rFiled.length && rFiled.map((subItem: string, i: number) => {
          if (rFiled.length === i + 1) {
            tmp1[subItem] = values[item.field];
          } else {
            if (!tmp1[subItem]) tmp1[subItem] = {};
            tmp1 = tmp1[subItem];
          }
        })
      })

      // 固定字段注入数据结构  (固定字段名若与表单字段名,会覆盖表单字段对应的值)
      fixedField.length && fixedField.map((item: any) => {
        const rFiled = item.structureFiled;
        if (!rFiled || !item.value) return;
        let tmp1 = postData;
        rFiled.length && rFiled.map((subItem: string, i: number) => {
          if (rFiled.length === i + 1) {
            tmp1[subItem] = item.value;
          } else {
            if (!tmp1[subItem]) tmp1[subItem] = {};
            tmp1 = tmp1[subItem];
          }
        })
      })

      // console.log("values", values);
      // console.log("postData", postData);
      // return;

      onSubmit && onSubmit(postData, form);
    })
  }

  // 点击删除图片,则删除图片内容中引用的该图片
  deleteLabelImgUploadCallback = (data: { deleteImgUrl: string, isRenderRichTextEditor: boolean, richTextEditorHtml: string }) => {

    const labelRichTextEditorSetting = this.props.setting.editFormSetting.find((itemSetting: any) => itemSetting.component === 'LabelRichTextEditor');
    const imgUrlReg = new RegExp(`<img src="${data.deleteImgUrl}.*?/>`, 'g');
    const imagesResource = this.props.form.getFieldValue(labelRichTextEditorSetting.imagesResourceField)
    let richTextEditorHtml = imgPlaceholderConvertToImgLabel(data.richTextEditorHtml, imagesResource);
    const matchResult = richTextEditorHtml.match(imgUrlReg);

    if (!matchResult) { return }

    const labelRichTextEditorField = labelRichTextEditorSetting.field;

    // 点击删除图片时,需要对富文本编辑器中的内容进行处理,因此需要先强制销毁 富文本编辑器组件
    this.props.form.setFieldsValue({
      [labelRichTextEditorField]: ''
    });
    this.setState({
      richTextEditor: {
        richTextEditorHtml: '',
        richTextEditorRender: false
      }
    }, () => {
      // 对富文本编辑器中的内容处理完后重新渲染 富文本编辑器组件
      richTextEditorHtml = richTextEditorHtml.replace(imgUrlReg, '')
      this.setState({
        richTextEditor: {
          richTextEditorHtml: richTextEditorHtml,
          richTextEditorRender: true
        }
      });
      this.props.form.setFieldsValue({
        [labelRichTextEditorField]: richTextEditorHtml
      });
    })
    //

    // await utils.delay(500)

    // // 对富文本编辑器中的内容处理完后重新渲染 富文本编辑器组件
    // richTextEditorHtml = richTextEditorHtml.replace(imgUrlReg, '')
    // this.setState({
    //   richTextEditor: {
    //     richTextEditorHtml: richTextEditorHtml,
    //     richTextEditorRender: true
    //   }
    // })
    // this.props.form.setFieldsValue({
    //   [labelRichTextEditorField]: richTextEditorHtml
    // })
    // labelRichTextEditorSetting.isRender = true

  }

  render(): React.ReactNode {

    const {
      form,
      setting: {
        editFormSetting = []
      },
      editType = ''
    } = this.props;
    const { formData = {} } = this.state;

    if (editType === '') {
      return null;
    }
    const formItemDom: React.ReactNode[] = [];
    if (editFormSetting.length) {
      editFormSetting.forEach((item: any, i) => {

        const { showCondition = {} } = item;
        let isShow = true;
        // 根据过滤条件过滤编辑组件,多个条件之间是(与)的关系
        Object.keys(showCondition).length && Object.keys(showCondition).map((item: string) => {
          if (item === 'editType') {
            // 据字段配置参数[showCondition.editType],控制新增或编辑场景下,是否渲染组件
            isShow = isShow && showCondition.editType.includes(editType);
          } else {
            let fieldValue = form.getFieldValue(item);
            isShow = isShow && (fieldValue || fieldValue === 0) && showCondition[item].includes(fieldValue)
          }
        })
        if (!isShow) return;

        if (item.component === null) return;
        if ((!item.component || !Field[item.component])) {
          console.error('找不到 [' + JSON.stringify(item) + '] 对应组件');
          return;
        }
        const copyItem = { ...item };

        const Comp = Field[copyItem.component];
        let disabled = null;
        if (editType === 'edit') {
          disabled = copyItem?.disabledWhenEdit || false;  // 控件在内容编辑场景下,依据字段配置参数[disabledWhenEdit],控制禁用状态
        } else if (editType === 'add') {
          disabled = copyItem?.disabledWhenAdd || false;  // 控件在内容添加场景下,依据字段配置参数[disabledWhenAdd],控制禁用状态
        }
        copyItem.disabled = item.disabled || disabled;  // 优先依据字段配置参数[disabled],控制禁用状态

        formItemDom.push(<Comp
          key={i}
          form={form}
          formData={formData}
          setting={copyItem}
          deleteLabelImgUploadCallback={item.component === 'LabelImgUpload' ? this.deleteLabelImgUploadCallback : null}
          richTextEditor={item.component === 'LabelRichTextEditor' ? (this.state?.richTextEditor || null) : null}
          getChildInstance={item.component === 'LabelRichTextEditor' ? (getChildInstanceCb: any) => this.getChildInstance = getChildInstanceCb : null}
        />)
      })
    }

    return <Form className="edit-form" onSubmit={this.onSubmit}>
      {formItemDom}
      <div className="btn-bar clearfix">
        <div className="ant-col ant-col-sm-5" />
        <div className="ant-col ant-col-sm-15">
          <Button type="primary" htmlType="submit" onClick={this.onSubmit}>提交</Button>
          <Button onClick={() => form.resetFields()}>重置</Button>
        </div>
      </div>
    </Form>
  }
}

const EditFormCreate: any = Form.create()(EditForm);
export default EditFormCreate;

richTextConvertTools.ts 文件:

// 字体大小 HTML 转换
export const fontSizeConvert = (htmlStr: string): string => {
  const fontSizeReg = new RegExp('<span style="font-size:[0-9]+px">', 'g');
  let matchResult = [...htmlStr.matchAll(fontSizeReg)].map((item: any[]) => item[0]);
  matchResult = [...new Set(matchResult)];
  matchResult.forEach((item: string) => {
    const size = item.match(/\d+/)?.[0];
    if (size) {
      const replaceTargetStr = `<span size="${size}">`
      htmlStr = htmlStr.replace(new RegExp(item, 'g'), replaceTargetStr)
    }
  })
  return htmlStr
}

// 颜色 HTML 转换
export const colorConvert = (htmlStr: string): string => {
  const colorReg = new RegExp('<span style="color:(#)?[0-9a-z]+">', 'g')
  let matchResult = [...htmlStr.matchAll(colorReg)].map((item: any[]) => item[0]);
  matchResult = [...new Set(matchResult)];
  matchResult.forEach((item: string) => {
    const color = item.match(/color:(#)?[0-9a-z]+/)?.[0]?.split(':')?.[0];
    if (color) {
      const replaceTargetStr = `<span color="${color}">`
      htmlStr = htmlStr.replace(new RegExp(item, 'g'), replaceTargetStr)
    }
  })
  return htmlStr
}

// 背景颜色 HTML 转换
export const bgColorConvert = (htmlStr: string): string => {
  const colorReg = new RegExp('<span style="background-color:(#)?[0-9a-z]+">', 'g')
  let matchResult = [...htmlStr.matchAll(colorReg)].map((item: any[]) => item[0]);
  matchResult = [...new Set(matchResult)];
  matchResult.forEach((item: string) => {
    const color = item.match(/background-color:(#)?[0-9a-z]+/)?.[0]?.split(':')?.[0];
    if (color) {
      const replaceTargetStr = `<span bgcolor="${color}">`
      htmlStr = htmlStr.replace(new RegExp(item, 'g'), replaceTargetStr)
    }
  })
  return htmlStr
}

// 文本对齐方式 HTML 转换
export const textAlignmentConvertToClass = (htmlStr: string): string => {
  const map = {
    left: 'tl',
    right: 'tr',
    center: 'tc',
    justify: 'tl'
  }

  const alignmentReg = new RegExp('<p style="text-align:(center|left|right|justify);" .*?altered="false">.*?</p>', 'g');
  let matchResult = [...htmlStr.matchAll(alignmentReg)].map((item: any[]) => item[0])
  matchResult = [...new Set(matchResult)];
  matchResult.forEach((item: string) => {
    const alignmentType = item.match(/text-align:(center|left|right|justify);/)?.[0];
    const text = item.replace(new RegExp('<p style="text-align:(center|left|right|justify);" .*?altered="false">'), '').replace('</p>', '')
    if (alignmentType) {
      const replaceTargetStr = `<p><span style="${alignmentType}">${text}</span></p>`;
      htmlStr = htmlStr.replace(new RegExp(item), replaceTargetStr)
    }
  })
  return htmlStr
}

// 粗体 HTML 转换
export const boldFaceConvert = (htmlStr: string): string => {
  htmlStr = htmlStr.replace(new RegExp('<strong>', 'g'), '<span style="font-weight: bold">').replace(new RegExp('</strong>', 'g'), '</span>')
  return htmlStr
}

// 下划线 HTML 转换
export const underlineConvert = (htmlStr: string): string => {
  htmlStr = htmlStr.replace(new RegExp('<u>', 'g'), '<span style="text-decoration: underline;"').replace(new RegExp('</u>', 'g'), '</span>')
  return htmlStr
}

// const outputImageHTML = (node: any) => {

//   if (node.classList.contains('media-wrap') || node.classList.contains('image-wrap') || node.classList.contains('align-center') || node.classList.contains('align-left') || node.classList.contains('align-right')) {
//     // const textAlign = node.
//     const imgNode = node.children
//     if (imgNode) {

//     }
//   }
// }
export function convert(richData: any, nodeCtrlFn: any) {
  const wrap = document.createElement('div');
  wrap.innerHTML = richData;

  function recurChilds(nodes: any, fn: any) {
    if (nodes) {
      for (const childNode of nodes) {
        fn(childNode);
        recurChilds(childNode.children, fn);
      }
    }
  }

  recurChilds(wrap.children, nodeCtrlFn);
  return wrap.innerHTML;
}

// 图片标签 转换为 占位符
export const imgConvertToPlaceholder = (htmlStr: string): string => {

  // convert(htmlStr, outputImageHTML)

  const imgWrapperReg = new RegExp('<img.*?#img:[0-9]+.*?/>', 'g')
  const emptyImgWrapperReg = new RegExp('<p class="media-wrap image-wrap"></p>', 'g')
  let matchResult = [...htmlStr.matchAll(imgWrapperReg)].map((item: any[]) => item[0]);
  matchResult.forEach((item: string) => {
    const imgPlaceHolder = item.match(/#img:\d+/)?.[0].replace('#', '');
    htmlStr = htmlStr.replace(new RegExp(item), `<!--{${imgPlaceHolder}}-->`)
  })
  htmlStr = htmlStr.replace(emptyImgWrapperReg, '');

  return htmlStr
}

// 图片占位符转换为 图片标签
export const imgPlaceholderConvertToImgLabel = (htmlStr: string, images: Array<{ url: string; image_size: number, [propsName: string]: any }>): string => {
  const imgPlaceHolderReg = new RegExp('<!--{img:[0-9]+}-->', 'g');
  let matchResult = [...htmlStr.matchAll(imgPlaceHolderReg)].map((item: any[]) => item[0]);
  matchResult.forEach((item: string) => {
    const index = item.match(/\d+/)?.[0]
    const imgSrc = images?.[index]?.url
    const replaceStr = `<img src="${imgSrc}" />`;
    htmlStr = htmlStr.replace(new RegExp(item), replaceStr)
  })
  return htmlStr
}