我最近在前端项目中要用到富文本编辑器,找来找去发现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