在antd项目中使用wangEditor经验

  • Post author:
  • Post category:其他


我最近在前端项目中要用到富文本编辑器,找来找去发现wangEditor很不错,目前已经迭代到v5版本,

WangEditor官方手册

是纯中文文档,内容比较详细,非常值得推荐使用。

我使用react开发,但官方手册对react开发者写的手册不够详尽,在使用过程中还是折腾了不少时间,其中多次提交issue叨唠开发者,项目开发者王福朋都不厌其烦地帮我解决了,非常感谢。在此我写一篇在antd中使用wangEditro开发手册致敬王福朋先生,支持开源项目。



安装

yarn add @wangeditor/editor-for-react
yarn add @wangeditor/editor



基本使用

官方文档里有示例代码,但是有一些问题,按我的改一下,ide不会提示飘红,对typescript支持更好。

import React, { useState, useEffect } from 'react'
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-react'
import type { IDomEditor } from '@wangeditor/editor';  // 引入类型

function MyEditor() {
    const [editor, setEditor] = useState<IDomEditor | null>(null) // 存储 editor 实例,指定editor的类型

    // `defaultContent` (JSON 格式) 和 `defaultHtml` (HTML 格式) 二选一
    // const defaultContent = [
    //     { type: "paragraph", children: [{ text: "一行文字" }], }
    // ]
    const defaultHtml = '<p>一行文字</p>'

    const toolbarConfig = { }
    const editorConfig = {
        placeholder: '请输入内容...',
        onCreated(editor) { setEditor(editor) } // 记录下 editor 实例,重要!
    }

    // 及时销毁editor,防止内存泄露,重要!
    useEffect(() => {
        return () => {
            if (editor == null) return
            editor.destroy()
            setEditor(null)
        }
    }, [editor])

    return (
        <>
            <div style={{ border: '1px solid #ccc', zIndex: 100}}>
                <Toolbar
                    editor={editor}
                    defaultConfig={toolbarConfig}
                    mode="default"
                    style={{ borderBottom: '1px solid #ccc' }}
                />
                <Editor
                    defaultConfig={editorConfig}
                    // defaultContent={defaultContent}
                    defaultHtml={defaultHtml}
                    mode="default"
                    style={{ height: '500px', overflowY: 'hidden' }}
                />
            </div>
        </>
    )
}

export default MyEditor



同步设置内容

官方手册里写了异步设置内容的方法,但我觉得实际使用中同步设置内容场景更多。应该将编辑器组件封装成功能单一的编辑器,请求数据应该安排其它组件(如dva发起请求)来完成。

同步设置内容分3块:



自定义编辑器函数头

将WangEditor进行封装,更方便使用,首先定义自定义编辑器的函数头:

const MyEditor: React.FC<{
    init: string,  // 初始数据
}> = (props) => {
    const {init} = props
}



定义useEffect监测init并将其导入编辑器



导入一个类型并定义一个常量

下面的代码写在组件上方,定义在组件外面!

import type { SlateDescendant } from '@wangeditor/editor'

const newNode: { type: string, children: SlateDescendant[] } = {  // 生成新节点
    type: 'paragraph',
    children: []
}



定义useEffect

注意,这个useEffect不能和前面的那个useEffect合并在一起写。之前的useEffect是专门负责在组件卸载时关闭编辑器并释放其内存的。下面这个useEffect是专门负责监测init变动并负责将其注入编辑器!

    useEffect(() => {
        if (editor) {
            editor.select([])  // 全选编辑器中的内容
            editor.deleteFragment()  // 删除编辑器中被选中内容
            SlateTransforms.setNodes(editor, newNode, { mode: "highest" })  // 配置编辑器使用新节点,节点模式设为最高级
            editor.dangerouslyInsertHtml(init)  // 插入html内容
        }
    }, [init])

在这个地方趟了不少雷,具体废话不多说了。尤其是官方手册里没有的那句代码SlateTransforms.setNodes,不加会遇到问题!另外注意里面的editor是之前useState生成的编辑器存储器,newNode是在myEditor组件外部上面定义的常量,{mode:“heighest”}参数也必不可少。



工具栏配置

工具栏配置相关内容官方手册写了getConfig、toolbarKeys、insertKeys、excludeKeys一大堆,感觉都是隔靴抓痒的玩意。干货看下面:

import type { IToolbarConfig } from '@wangeditor/editor' // 在组件外面最上方要引入的类型

const toolbarConfig: Partial<IToolbarConfig> = {  // 在组件外面上方定义的常量
    toolbarKeys: [
        'undo',  // 取消
        'redo',  // 重做
        'headerSelect',  // 标题类型
        'fontFamily',  // 字体类型
        'fontSize',  // 字体大小
        'lineHeight',  // 行高
        '|',  // 分割线
        'bold',  // 字体加粗
        'italic',  // 字体倾斜
        'underline',  // 下划线
        'through',  // 删除线
        // 'sub',  // 上标
        // 'sup',  // 下标
        // "clearStyle",  // 清除样式
        'color',  // 文字颜色
        'bgColor',  // 背景色
        '|',  // 分割线
        // 'indent',  // 增加缩进
        // 'delIndent',  // 减少缩进
        'bulletedList',  // 无序列表
        'numberedList',  // 有序列表
        'justifyLeft',  // 左对齐
        'justifyRight',  // 右对齐
        'justifyCenter',  // 居中
        'justifyJustify',  // 两端对齐
        '|',  // 分割线
        'divider',  // 插入分割线
        // 'todo',  // 待办
        'insertTable',  // 插入表格
        // "deleteTable",  // 删除表格
        // "insertTableRow",  // 插入表格行
        // "deleteTableRow",  // 删除表格行
        // "insertTableCol",  // 插入表格列
        // "deleteTableCol",  // 删除表格列
        // "emotion",  // 插入表情符号
        'blockquote',  // 引用
        // 'codeBlock',  // 代码块
        'uploadImage',  // 上传图片
        // "uploadVideo",  // 上传视频
        // "insertImage",  // 插入网络图片
        // "deleteImage",  // 删除图片
        // "editImage",  // 编辑图片
        // "viewImageLink",  // 查看图片链接
        // "imageWidth30",  // 图片宽度设置为30%
        // "imageWidth50",  // 图片宽度设置为50%
        // "imageWidth100",  // 图片宽度设置为100%
        "insertLink",  // 插入链接
        // "editLink",  // 修改链接
        // "unLink",  // 删除链接
        // "viewLink",  // 查看链接
        'fullScreen',  // 全屏
    ],
}

最常用的配置都在上面了,还有一些基本上用不到的配置项没列出来,具体可以取官方手册查。使用我的模板会很方便,要用哪个就保留哪个,要禁哪个就注释哪个,按钮的顺序是按照定义的顺序(可以按自己需要调整),”|”按键之间分割线也可按需要设置。



编辑器配置

我这写一下我用到的字体配置和图片上传配置,我喜欢用简单粗暴json方式配置,不喜欢官方手册里的那种变量属性赋值(有点弯弯绕绕)的写法,请看代码:

import type { IEditorConfig } from '@wangeditor/editor' // 在组件外面最上方要引入的类型


	// 在组件内部的代码
    const [editor, setEditor] = useState<IDomEditor | null>(null) // 存储 editor 实例
    const editorConfig: Partial<IEditorConfig> = {
        placeholder: '内容不能为空',
        onCreated(editorCase: IDomEditor) {
            setEditor(editorCase)
        }, // 记录下 editor 实例,重要!
        MENU_CONF: {
            fontFamily: {  // 配置可选字体
                fontFamilyList: [
                    '黑体',
                    '仿宋',
                    '楷体',
                    '宋体',
                    '微软雅黑',
                    'Arial',
                    'Tahoma',
                    'Courier New',
                ]
            },
            uploadImage: {  // 配置图片上传服务器
                server: '/api/upload',
                uploadImage: [],  // 不限制上传文件类型
                maxNumberOfFiles: 1,  // 单次最多上传一个文件
                // 单个文件上传成功之后
                onSuccess(file: File) {
                    message.success(`${file.name} 上传成功`)  // antd的message组件
                },
                // 单个文件上传失败
                onFailed(file: File) {
                    message.error(`${file.name} 上传失败`)  // antd的message组件
                },
                // 上传错误,或者触发 timeout 超时
                onError(file: File, err: any) {
                    message.error(`${file.name} 上传出错`+String(err))  // antd的message组件
                },
            }
        }
    }



总结

基本上react使用WangEditor按上面的写法基本不会再遇到雷了。另外我写的是typescript,引入很多类型来完善类型提示,ide不会有任何飘红。还有我喜欢在组件外面上方定义很多常量,能在组件外面定义的都放组件外面了。

最后,在antd的表单中中使用wangEditor还有些注意事项,我把我的MyEditor的完整代码贴在下面,有需要的可以看看代码和注释,基本上就没啥问题了。

import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'
import type { ProFormInstance } from '@ant-design/pro-form'
import { ProForm, ProFormSelect, ProFormText } from '@ant-design/pro-form'
import { setNotice } from '@/services/notice'
import { message } from "antd";
import { Editor, Toolbar } from '@wangeditor/editor-for-react'
import type { IDomEditor, IEditorConfig, IToolbarConfig, SlateDescendant } from '@wangeditor/editor';
import { SlateTransforms } from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css'
import '@/components/ul.less'


const sectionList: ApiRes.SelectOption[] = [
    {
        label: "本部门",
        value: 0
    },
    {
        label: "所有部门",
        value: 9
    },
]
const releaseList: ApiRes.SelectOption[] = [
    {
        label: "未发布",
        value: 0
    },
    {
        label: "已发布",
        value: 1
    },
]
const newNode: { type: string, children: SlateDescendant[] } = {  // 生成新节点
    type: 'paragraph',
    children: []
}
const toolbarConfig: Partial<IToolbarConfig> = {
    toolbarKeys: [
        'undo',  // 取消
        'redo',  // 重做
        'headerSelect',  // 标题类型
        'fontFamily',  // 字体类型
        'fontSize',  // 字体大小
        'lineHeight',  // 行高
        '|',  // 分割线
        'bold',  // 字体加粗
        'italic',  // 字体倾斜
        'underline',  // 下划线
        'through',  // 删除线
        // 'sub',  // 上标
        // 'sup',  // 下标
        // "clearStyle",  // 清除样式
        'color',  // 文字颜色
        'bgColor',  // 背景色
        '|',  // 分割线
        // 'indent',  // 增加缩进
        // 'delIndent',  // 减少缩进
        'bulletedList',  // 无序列表
        'numberedList',  // 有序列表
        'justifyLeft',  // 左对齐
        'justifyRight',  // 右对齐
        'justifyCenter',  // 居中
        'justifyJustify',  // 两端对齐
        '|',  // 分割线
        'divider',  // 插入分割线
        // 'todo',  // 待办
        'insertTable',  // 插入表格
        // "deleteTable",  // 删除表格
        // "insertTableRow",  // 插入表格行
        // "deleteTableRow",  // 删除表格行
        // "insertTableCol",  // 插入表格列
        // "deleteTableCol",  // 删除表格列
        // "emotion",  // 插入表情符号
        'blockquote',  // 引用
        // 'codeBlock',  // 代码块
        'uploadImage',  // 上传图片
        // "uploadVideo",  // 上传视频
        // "insertImage",  // 插入网络图片
        // "deleteImage",  // 删除图片
        // "editImage",  // 编辑图片
        // "viewImageLink",  // 查看图片链接
        // "imageWidth30",  // 图片宽度设置为30%
        // "imageWidth50",  // 图片宽度设置为50%
        // "imageWidth100",  // 图片宽度设置为100%
        "insertLink",  // 插入链接
        // "editLink",  // 修改链接
        // "unLink",  // 删除链接
        // "viewLink",  // 查看链接
        'fullScreen',  // 全屏
    ],
}
const MyEditor: React.FC<{
    init: Props.FormEditor,  // 表单初始数据,自定义的namespace Props中的FormEditor类型
    onRef: React.RefObject<any>,  // 父组件ref,为了在父组件中回调子组件的表单控件
    dispatch: (param: { type: string, payload: any }) => void,
}> = (props) => {
    const { init, onRef, dispatch } = props
    const formRef = useRef<ProFormInstance<Props.FormEditor>>()  // 绑定form控件,用来设置表单默认值
    useImperativeHandle(onRef, () => ({ formRef }))  // 将表单控件暴露给父组件
    const [editor, setEditor] = useState<IDomEditor | null>(null) // 存储 editor 实例
    const editorConfig: Partial<IEditorConfig> = {
        placeholder: '内容不能为空',
        onCreated(editorCase: IDomEditor) {
            setEditor(editorCase)
        }, // 记录下 editor 实例,重要!
        MENU_CONF: {
            fontFamily: {  // 配置可选字体
                fontFamilyList: [
                    '黑体',
                    '仿宋',
                    '楷体',
                    '宋体',
                    '微软雅黑',
                    'Arial',
                    'Tahoma',
                    'Courier New',
                ]
            },
            uploadImage: {  // 配置图片上传服务器
                server: '/api/upload',
                uploadImage: [],
                maxNumberOfFiles: 1,  // 单次最多上传一个文件
                // 单个文件上传成功之后
                onSuccess(file: File) {
                    message.success(`${file.name} 上传成功`)
                },
                // 单个文件上传失败
                onFailed(file: File) {
                    message.error(`${file.name} 上传失败`)
                },
                // 上传错误,或者触发 timeout 超时
                onError(file: File, err: any) {
                    message.error(`${file.name} 上传出错` + String(err))
                },
            }
        }
    }
    useEffect(() => {
        formRef.current?.setFieldsValue(init)  // 初始化表单默认值
        if (editor) {
            editor.select([])  // 全选编辑器中的内容
            editor.deleteFragment()  // 删除编辑器中被选中内容
            SlateTransforms.setNodes(editor, newNode, { mode: "highest" })  // 配置编辑器使用新节点,节点模式设为最高级
            editor.dangerouslyInsertHtml(init.desc)  // 插入html内容
        }
    }, [init])
    useEffect(() => {  // 及时销毁editor,防止内存泄露!
        return () => {
            if (editor == null) return
            editor.destroy()
            setEditor(null)
        }
    }, [editor])
    return (
        <ProForm
            formRef={formRef}
            submitter={{ render: () => <></> }}
            style={{ margin: 0, padding: 0 }}
            onFinish={async (values: any) => {
                const result = { ...values, desc: editor!.getHtml(), noticeId: init.noticeId }
                const msg = await setNotice(result)  // 提交数据到服务器
                if (msg.state) {
                    message.success(msg.message)
                    // 刷新通知数据及当前通知状态
                    dispatch({ type: "notice/refreshNotice", payload: { param: "edit", id: msg.data } })
                } else {
                    message.error(msg.message)
                }
            }}
        >
            <ProForm.Group>
                <ProFormText
                    width={700}
                    name="title"
                    label="通知标题"
                    placeholder="请输入通知标题"
                    validateTrigger="onBlur"
                    rules={[
                        {
                            required: true,
                            message: '请输入通知标题!',
                        },
                        {
                            min: 2,
                            message: '标题最少2个字!'
                        },
                        {
                            max: 50,
                            message: '标题最多50个字!'
                        }
                    ]}
                />
                <ProFormSelect
                    width={160}
                    name="sectionId"
                    label="通知机构"
                    fieldProps={{
                        defaultValue: 0,
                    }}
                    placeholder="请选择通知范围"
                    options={sectionList}
                    rules={[
                        {
                            required: true,
                            message: '请选择通知范围!',
                        },
                    ]}
                />
                <ProFormSelect
                    width={160}
                    name="releaseStatus"
                    label="发布状态"
                    fieldProps={{
                        defaultValue: 0,
                    }}
                    placeholder="请选择发布状态"
                    options={releaseList}
                    rules={[
                        {
                            required: true,
                            message: '请选择发布状态!',
                        },
                    ]}
                />
                <ProForm.Item
                    label="通知内容"
                    name="desc"
                    rules={[
                        {
                            required: true,
                            validator: () => {
                                if (editor === null) {
                                    return Promise.reject('内容不能为空')
                                } else if (editor.getText().length < 3) {
                                    return Promise.reject('内容不能少于3个字符')
                                } else {
                                    return Promise.resolve()
                                }
                            },
                        },
                    ]}
                >
                    <div style={{ border: '1px solid #ccc', zIndex: 100, width: 1188 }}>
                        <Toolbar
                            editor={editor}
                            defaultConfig={toolbarConfig}
                            mode="simple"
                            style={{ borderBottom: '1px solid #ccc' }}
                        />
                        <Editor
                            defaultConfig={editorConfig}
                            defaultHtml={init.desc}
                            mode="simple"
                            style={{
                                height: '420px',
                                margin: 0,
                                padding: 0,
                                marginBottom: 65,
                                overflowY: 'hidden'
                            }}
                        />
                    </div>
                </ProForm.Item>
            </ProForm.Group>
        </ProForm>
    )
}

export default MyEditor



版权声明:本文为m0_47670683原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。