在实际业务场景中,需要对文本内容进行富文本编辑。
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
}