一、背景

继之前的CKEditor5学习开发之路之后,这又是一篇对富文本编辑器的学习与使用的整理回顾笔记。之所以会再一次研究富文本编辑器,并且目标对象变了,主要是因为CKEditor5的License为GNU General Public License,要求使用者也必须开源,因此只能重新调研,具体调研结果可查看富文本编辑器调研

二、技术选型

从调研结果来看,除掉CKEditor5,还剩下TinyMCE、wangEditor、Froala Editor是可选的。

  • TinyMCE

    1. 先来说TinyMCE,它的内容区是嵌套了一个iframe,扩展插件的话是新建html、js,在这独立的html、js中写插件的逻辑,而这插件被集成到编辑器并使用的时候,又嵌套了一层iframe。如果要实现插入图表的功能,图表的插入使用肯定不止一个,这种方案似乎不太合适。
    2. 另外它的引用方式也挺奇怪的,默认地,代码会去他们的服务器请求tinymce.min.js,如果是内网环境,则需要单独配置,具体参考这里的步骤8。
    3. 基于这两点原因,最终放弃了它。这里是测试代码
  • Froala Editor看起来是个不错的选择,但查看代码时,发现前端工程依赖的"froala-editor": "^4.1.1"并没有公开代码仓,公开出来的是V3版本,其他的公开仓都是适配各种框架的,这。。。开源了个啥

  • wangEditor很明显也不是理想选择,正如调研结果中提到的,bug较多,但没办法,暂时没找到其他开源可商用的富文本编辑器。

三、wangEditor的使用与扩展

不过wangEditor的优势也是比较明显的,帮助文档很详细,示例较多,源码结构也很清晰,弄清楚了它的代码逻辑,修复遇到的bug还是比较简单的。
架构图.png
这里就不再重复罗列自定义扩展新功能的步骤了,按照其官网教程一步步来即可。附上包含插入图表、占位符的示例代码
只记录下对其bug修复、功能调整的技术细节。

1、修复在Angular工程中编译报错的bug

1
2
3
// packages/core/src/editor/interface.ts
// import ee from 'event-emitter'
import * as ee from 'event-emitter' // @types/event-emitter中是这么写的:export = ee; 所以需要import * as ee from 'event-emitter'

2、修复不设置编辑器高度时,控制台有警告提示、hoverbar、modal位置不对的bug

扩展IEditorConfig,使之支持设置minHeight,然后在创建根节点时,设置最小高度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// packages/core/src/config/interface.ts
/**
* editor config
*/
export interface IEditorConfig {
...
minHeight?: string
}

// packages/core/src/text-area/update-view.ts
/**
* 生成编辑区域的 elem
* @param elemId elemId
* @param readOnly readOnly
*/
function genRootElem(elemId: string, readOnly = false, minHeight = ''): Dom7Array {
const style = minHeight ? `style="min-height: ${minHeight};"` : ''
const $elem = $(`<div
id="${elemId}"
data-slate-editor
data-slate-node="value"
suppressContentEditableWarning
role="textarea"
spellCheck="true"
autoCorrect="true"
autoCapitalize="true"
${style}
></div>`)

// role="textarea" - 增强语义,div 语义太弱

return $elem
}

3、修复插入分隔线或自定义元素时,总是会在前面空出一行的bug

参考插入表格的代码,如果当前是空 p ,则删除该 p

1
2
3
4
5
// packages/table-module/src/module/menu/InsertTable.ts
// 如果当前是空 p ,则删除该 p
if (DomEditor.isSelectedEmptyParagraph(editor)) {
Transforms.removeNodes(editor, { mode: 'highest' })
}

4、修复给到编辑器的html中出现了字号列表中没有的字号时,字号不生效的bug

移除在字号列表中查找当前字号的逻辑(字体也是一样的逻辑)

1
2
3
4
5
6
7
8
9
10
// packages/basic-modules/src/modules/font-size-family/parse-style-html.ts
// const includesSize =
// fontSizeList.find(item => item.value && item.value === fontSize) ||
// fontSizeList.includes(fontSize)
// 在 fontSizeList 中找不到,也能够设置 fontSize
const includesSize = true

if (fontSize && includesSize) {
textNode.fontSize = fontSize
}

5、调整表格创建成功之后宽度默认100%

获取表格节点时,将width: 'auto'改为width: '100%'即可

1
2
3
4
5
6
7
8
9
// packages/table-module/src/module/menu/InsertTable.ts
function genTableNode(rowNum: number, colNum: number): TableElement {
...
return {
type: 'table',
width: '100%',
children: rows,
}
}

6、扩展工具栏按钮BaseButton,使之支持调用menu的onButtonClick方法

有时候我们需要在点击工具栏按钮的时候,获取按钮的位置,以便靠近该按钮显示一个自定义浮窗,此时我们需要知道按钮相对编辑器的位置,以确定浮窗的位置。为此,我们扩展BaseButton,使之支持调用menu的onButtonClick方法(当然,前提是menu实现了onButtonClick方法),然后将event传递出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// BaseButton本身已经有onButtonClick方法了,只是menu没有,所以对menu的接口IBaseMenu进行扩展
// packages/core/src/menus/interface.ts
interface IBaseMenu {
...
onButtonClick?: (editor: IDomEditor, e: Event) => void // 和 exec 类似,但主要是为了得到原始 event
}

// packages/core/src/menus/bar-item/BaseButton.ts
// // 交给子类去扩展
// abstract onButtonClick(): void
/**
* 执行 menu.onButtonClick
*/
protected onButtonClick(e: Event) {
const editor = getEditorInstance(this)
const menu = this.menu
menu.onButtonClick && menu.onButtonClick(editor, e)
}

// 注意:BaseButton的所有子类,均需在onButtonClick中调用父类的该方法
onButtonClick(e: Event) {
super.onButtonClick(e)
...
}

7、扩展工具栏下拉选择Select,使之支持始终显示图标,而不是显示选中的文字

比如行高,有些富文本编辑器始终显示图标(当然也不能说这样是最好,只是扩展以支持该功能)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// packages/core/src/menus/interface.ts
export interface ISelectMenu extends IBaseMenu {
readonly alwaysShowIcon?: boolean // 永远显示图标
...
}

// packages/core/src/menus/bar-item/Select.ts
private setSelectedValue() {
const editor = getEditorInstance(this)
const menu = this.menu
const { alwaysShowIcon, iconSvg } = menu
const value = menu.getValue(editor)

const options = menu.getOptions(editor)
const optText = getOptionText(options, value.toString())

const $button = this.$button
const $downArrow = gen$downArrow() // 向下的箭头图标
$button.empty()
// 主要是这里的代码
if (alwaysShowIcon && iconSvg) {
const $svg = $(iconSvg)
clearSvgStyle($svg)
$button.append($svg)
} else {
$button.text(optText)
}
$button.append($downArrow)
}

四、wangEditor私有化

上面我们对其源码修改了这么多,最好是贡献给wangEditor,但存在几点障碍:

  • 作者已经在 2023-08-30 发布了 wangEditor 暂停维护的通知,具体看这里
  • 我们的修改不一定能够被他们接受(或许有些问题他们不认为是bug,或许有些扩展他们认为不通用)
  • 加入其团队略微麻烦,具体要求在这里

所以我们需要重新定义包名、指定代码仓、指定npm私服。
源码中是使用lerna管理多个包的,因此packages目录下的每个包都需要改,主要修改package.json中的如下属性值:

1
2
3
4
5
6
7
8
9
{
"name": "xxx",
"publishConfig": {
"registry": "xxx"
},
"repository": {
"url": "xxx"
}
}

需要注意一点,如果我们新的代码仓不是在GitHub上,那么在lerna将git tag push到远程时,就不会触发原有的配置git action,此时需要我们手动发包。
所以,调整之后的完整的开发、发包流程如下:

1
2
3
4
5
6
- 下载代码到本地,进入 `wangEditor` 目录
- 安装所有依赖 `yarn bootstrap`
- 开发功能,完成之后将代码合并到 `master` 分支
- 打包所有模块 `yarn dev` 或者 `yarn build`
- 生成版本并利用 lerna 自动 push `yarn release:version`
- 手动发包 `yarn release:publish`