更新
wangEditor V5 已正式发布,查看官网。
背景和现状
wangEditor 正在设计新版本,力争做一个更加稳定、简洁的开源富文本编辑器。
- 弃用
document.execCommand
,分离 view 和 model - 未来可支持协同编辑
- 充分考虑扩展性和插件机制,以便于扩展更加复杂的功能
- 程序员的技术追求,一直走在折腾自己的道路上~
我此前已经做过一些工作
- 从 0 开发一个 demo ,根据实践的问题去调研 slate quill proseMirror ,已记录到这篇文章
- 尝试使用 slate (不依赖 React)做 demo
- 确定以 slate 为内核(不依赖 React),开始尝试设计新版本(WIP 源码未开放)
虽然已经实现部分功能,但目前还处于技术方案设计过程中,API 和代码结构还会继续调整。
为何要基于 slate.js ?
最初也想从 0 开始自研内核,后来慢慢改变了主意。特别是当看到 proseMirror 那一大堆代码之后。
为何不是自研内核?
- 我们的核心目的,是做出一款稳定、易用、高扩展性的开源产品,并不是搞极客精神和造轮子。
- 自研成本非常高,耗时长,bug 多。而且如果要做,基本都会压在我身上 —— 而我的个人精力是无法保证的,这会儿闲了,说不定过两天就忙起来
- PS:如果你有深度的技术追求,可以先从解读源码、写 demo 开始,看个人精力和能力。
对比其他开源产品
quill 不合适
- 已是一个成熟的编辑器,内核、UI、插件等都已经成型了,生态也很大,拿来即用,我们能做的很少
- quill 最新版本已是 2 年之前发布的了
- quill 的 delta 有很大的学习成本
proseMirror 不合适
- 它不是一个精简 core ,涉及的内容非常多,代码量大,包体积大
- 设计比较抽象,代码复杂,不易解读
slate 比较合适
- 设计简单易理解,代码量少,包体积小(仅有 60kb gzip 17kb),源码易读
- 基于插件机制,万事可扩展
- 默认基于 React ,但可以通过二次开发自定义 view 层(已做)
基于 slate.js 并不意味着就很简单
- 不依赖 React ,重写 View
- 设计各种操作功能,如 toolbar 、tooltip 等
- 设计扩展性,全面插件化
- 开发各个富文本编辑器功能,特别是复杂的功能,如表格、代码高亮
就好比,我基于一台现成的发动机,去设计一辆整车,这并不简单。
整体设计
基于 lerna ,拆分多个 packages
底层依赖
- slate.js – 编辑器内核
- snbbdom – view model 分离,使用 vdom 渲染 view
- 【注意】公共依赖,要合理使用 peerDependencies ,避免重复打包!!!
core
严格来说应该叫做“view core”。它基于 slate.js 内核,完成编辑器的 UI 部分。
- editor – 定义 slate 用于 DOM UI 的一些 API
- text-area – 输入区域 DOM 渲染,DOM 事件,选区同步,
- formats – 输入区域不同数据的渲染规则,如怎样渲染加粗、颜色、图片、list 等。可扩展注册。
- menus – 菜单,包括 toolbar、悬浮菜单、tooltip、右键菜单、DropPanel、Modal 等。可扩展注册。
core 本身没有任何实际功能。需要通过 module 来扩展 formats、menus、plugins 等,来定义具体的功能。
大到复杂的表格功能,小到简单的加粗功能,都需要这样处理。
basic modules
汇总了一些常见的、简单的、基础的 module 。例如:
- simple-style – 加粗、斜体、下划线、删除线、行内代码
- color – 文字颜色、背景色
- header – 设置标题
- 其他…
一些比较复杂的 module ,需要单独拆分为一个 package ,例如 table 、code-block 等。 反正是基于 lerna 搭建,扩展 package 也比较简单。
editor
引入 core ,引入各个 module ,然后根据用户配置,最终生成一个编辑器。
core 的设计
上文提到过,core 严格来说应该叫做 view-core 。它的核心作用:
- 劫持用户输入和修改,使用 editor API 去触发 model 修改(或者 selection 修改)
- editor change 要实时更新到 DOM ,保持 DOM 和 model(或 selection)的同步
- 定义扩展机制(它本身没有什么实际功能),通过扩展 module 来实现具体的功能
使用 beforeinput 劫持用户输入
beforeinput 是一个比较新的 DOM 事件,去年还没有得到普遍支持。当前看来,主流浏览器已经支持了,特别是 FireFox 87 发布之后,参考 caniuse 。
对于还不支持的浏览器,可以用 keydown/keypress 来兼容,虽然会有一些影响,但好在用户占比不大。 (PS:新版本不再支持 IE11)
监听 beforeinput 事件,然后根据不同的 inputType 执行不同的 editor API 。
// 阻止默认行为,劫持所有的富文本输入
event.preventDefault()
// 根据 beforeInput 的 event.inputType
switch (type) {
case 'deleteByComposition':
case 'deleteByCut':
case 'deleteByDrag': {
Editor.deleteFragment(editor)
break
}
case 'deleteContent':
case 'deleteContentForward': {
Editor.deleteForward(editor)
break
}
case 'deleteContentBackward': {
Editor.deleteBackward(editor)
break
}
case 'deleteEntireSoftLine': {
Editor.deleteBackward(editor, { unit: 'line' })
Editor.deleteForward(editor, { unit: 'line' })
break
}
case 'deleteHardLineBackward': {
Editor.deleteBackward(editor, { unit: 'block' })
break
}
case 'deleteSoftLineBackward': {
Editor.deleteBackward(editor, { unit: 'line' })
break
}
case 'deleteHardLineForward': {
Editor.deleteForward(editor, { unit: 'block' })
break
}
case 'deleteSoftLineForward': {
Editor.deleteForward(editor, { unit: 'line' })
break
}
case 'deleteWordBackward': {
Editor.deleteBackward(editor, { unit: 'word' })
break
}
case 'deleteWordForward': {
Editor.deleteForward(editor, { unit: 'word' })
break
}
case 'insertLineBreak':
case 'insertParagraph': {
Editor.insertBreak(editor)
break
}
case 'insertFromComposition':
case 'insertFromDrop':
case 'insertFromPaste':
case 'insertFromYank':
case 'insertReplacementText':
case 'insertText': {
if (data instanceof DataTransfer) {
// 这里处理非纯文本(如 html 图片文件等)的粘贴。对于纯文本的粘贴,使用 paste 事件
DomEditor.insertData(editor, data)
} else if (typeof data === 'string') {
Editor.insertText(editor, data)
}
break
}
}
selection 同步
DOM selection 变化会触发 document.addEvenListener('selectionchange', fn)
editor selection 变化会触发 editor.onChange
事件。这样就可以做到相互同步。
updateView 同步视图
editor onChange 时会触发更新视图,以保证 view 和 model 实时同步。分为两步:
- 根据 model 生成 vnode
- patch vnode
第二步很简单,我们利用 snabbdom.js 来做 vdom 渲染。vue 2.x 用的这个库,老牌的,稳定性好一些。 而且,它通过简单配置即可支持 jsx ,写起来非常非常方便。
关键在于第一步,生成 vnode 。 下面代码经过了简化,便于阅读,我们将逻辑拆分为两段:renderElement
和 renderText
/** * 根据 slate node 生成 snabbdom vnode * @param node slate node * @param index node index in parent.children * @param parent parent slate node * @param editor editor */
function node2Vnode(node: SlateNode, index: number, parent: SlateAncestor, editor: IDomEditor): VNode {
if (node.type && node.text) {
throw new Error(` no node can not have both 'type' and 'text' prop! 一个节点不能同时拥有 type 和 text 两个属性! ${JSON.stringify(node)} `)
}
let vnode: VNode
if (Element.isElement(node)) {
// element
vnode = renderElement(node as Element, editor)
} else {
// text
vnode = renderText(node as Text, parent, editor)
}
return vnode
}
renderElment
renderElement 简化代码如下
// renderElement 简化代码
function renderElement(elemNode: SlateElement, editor: IDomEditor): VNode {
// 根据 type 生成 vnode 的函数
const { type, children = [] } = elemNode
let genVnodeFn = getRenderFn(type)
const childrenVnode = isVoid
? null // void 节点 render elem 时不传入 children
: children.map((child: Node, index: number) => {
return node2Vnode(child, index, elemNode, editor)
})
// 创建 vnode
let vnode = genVnodeFn(elemNode, childrenVnode, editor)
return vnode
}
代码中,通过 node.type 获取 genVnodeFn ,即当前节点生成 vnode 的函数。
该函数代码如下,即默认情况下会使用 <div>
或者 <span>
来渲染节点。
/** * 默认的 render elem * @param elemNode elem * @param editor editor * @param children children vnode * @returns vnode */
function defaultRender( elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor ): VNode {
const Tag = editor.isInline(elemNode) ? 'span' : 'div'
const vnode = <Tag>{children}</Tag>
return vnode
}
/** * 根据 elemNode.type 获取 renderElement 函数 * @param type elemNode.type */
function getRenderFn(type: string): RenderElemFnType {
const fn = RENDER_ELEM_CONF[type]
return fn || defaultRender
}
当然,有默认,就有自定义扩展(很重要)。例如最简单的,type 是 'paragraph'
时,可以这样扩展:
function renderParagraph( elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor ): VNode {
const vnode = <p>{children}</p>
return vnode
}
export const renderParagraphConf = {
type: 'paragraph',
renderFn: renderParagraph,
}
最后,根据 genVnodeFn 即可生成当前节点的 vnode 。子节点无论是 element 还是 text ,都交给上述的 node2Vnode
函数来统一处理。
renderText
renderText 简化代码如下
// renderText 简化代码
function renderText(textNode: SlateText, parent: Ancestor, editor: IDomEditor): VNode {
// 生成 leaves vnode - 每个 text 节点都可拆分为若干个 leaf 节点
const leavesVnode = leaves.map((leafNode, index) => {
// 文字和样式
const isLast = index === leaves.length - 1
let strVnode = genTextVnode(leafNode, isLast, textNode, parent, editor)
strVnode = addTextVnodeStyle(leafNode, strVnode)
// 生成每一个 leaf 节点
return <span data-slate-leaf>{strVnode}</span>
})
// 生成 text vnode
const textId = `w-e-text-${key.id}`
const vnode = (
<span data-slate-node="text" id={textId} key={key.id}> {leavesVnode /* 一个 text 可能包含多个 leaf */} </span>
)
return vnode
}
其中最关键的是对文本样式的渲染,即 addTextVnodeStyle
函数,这也是可扩展的。例如
function addTextStyle(node: SlateText | SlateElement, vnode: VNode): VNode {
const { bold, italic, underline, code, through } = node
let styleVnode: VNode = vnode
if (bold) {
styleVnode = <strong>{styleVnode}</strong>
}
if (code) {
styleVnode = <code>{styleVnode}</code>
}
if (italic) {
styleVnode = <em>{styleVnode}</em>
}
if (underline) {
styleVnode = <u>{styleVnode}</u>
}
if (through) {
styleVnode = <s>{styleVnode}</s>
}
return styleVnode
}
总之,无论是渲染 element 还是 text ,都支持通过 module 来扩展。
这样不仅能保证支持多种格式、功能,而且代码逻辑也都会分散到各个 module 中,做好隔离。
menus 支持各种菜单
menu 应该是一个抽象,基于它来生成各种类型的菜单:
- 传统的工具栏
- 选中文字、元素之后的悬浮菜单
- 右键菜单等
- 还要支持各种类型:button select 等
目前对 menu 的定义如下:
interface IOption {
value: string
text: string
selected?: boolean
styleForRenderMenuList?: { [key: string]: string } // 渲染菜单 list 时的样式
}
export interface IMenuItem {
title: string
iconSvg: string
tag: string // 'button' / 'select'
showDropPanel?: boolean // 点击 'button' 显示 dropPanel
options?: IOption[] // select -> options
width?: number // 设置 button 宽度
getValue: (editor: IDomEditor) => string | boolean
isDisabled: (editor: IDomEditor) => boolean
exec?: (editor: IDomEditor, value: string | boolean) => void // button click 或 select change 时触发
getPanelContentElem?: (editor: IDomEditor) => Dom7Array // showDropPanel 情况下,获取 content elem
// 后续还可能继续扩展其他能力,但尽量保证简洁、易读
}
通过以上定义,可以支持如下菜单形式。其他的还在设计开发中。
editor API 和 plugins
参考 slate-react 源码,定义了一些全局 command ,在渲染 DOM 时很有用。
封装了一个 slate 插件,增加/重写 API
这个插件是 core 里自带的。还可以继续再扩展其他插件,即在 module 中扩展。
module 的设计
core 没有任何基础功能,所有功能都是 module 来扩展实现。module 可扩展的有:
- menu
- formats
- renderElement
- addTextStyle
- plugin(即 slate plugin)
最终,每个 module 可输出这样一个数据格式,以注册到 core 中
interface IRenderElemConf {
type: string
renderFn: RenderElemFnType
}
interface IMenuConf {
key: string
factory: () => IMenuItem
config?: { [key: string]: any }
}
// module 数据格式
export interface IModuleConf {
addTextStyle?: TextStyleFnType
renderElems?: Array<IRenderElemConf>
menus?: Array<IMenuConf>
editorPlugin?: <T extends Editor>(editor: T) => T
// 后续可能会对格式做一些调整,但整体范围不会大变
}
扩展 addTextStyle
上文已提到过,就是对 text 节点进行样式渲染。上文已有对加粗、斜体、下划线等的代码。
下面是字体颜色、背景色的代码:
/** * 文字样式 - 字体颜色/背景色 * @param node slate node * @param vnode vnode * @returns vnode */
export function addTextStyle(node: SlateText | SlateElement, vnode: VNode): VNode {
const { color, bgColor } = node
let styleVnode: VNode = vnode
if (color) {
addVnodeStyle(styleVnode, { color }) // 给 vnode 添加样式
}
if (bgColor) {
addVnodeStyle(styleVnode, { backgroundColor: bgColor }) // 给 vnode 添加样式
}
return styleVnode
}
PS:这里比较麻烦的是代码高亮。
扩展 renderElement
给 node.type 定义一个函数,输入 slate node ,输出 vnode 。
// render h1
function renderHeader1( elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor ): VNode {
const vnode = <h1>{children}</h1>
return vnode
}
export const renderHeader1Conf = {
type: 'header1',
renderFn: renderHeader1,
}
扩展 menu
menu 是一个抽象,上文定义了它的接口格式 IMenuItem
。
button menu
如加粗、下划线等
简单解释一下:
getValue
函数确定当前的状态,如是否加粗。isDisabled
判断当前 menu 是否可用,如在代码块中,bold 不可用。exec
即 menu 按钮点击时执行的方法。注意tag = 'button'
是 button 类型的。
select menu
如设置标题,它需要定义 options 。
showDropPanel
如设置颜色,需要 dropPanel 。则该 menu 需要一个 getPanelContentElem 以定义 dropPanel 的内容 DOM 。
目前只有这三种类型,后续还可能扩展其他类型。
menu config
有些 menu 需要一些配置,如颜色、字体、行高等,很多。
V4 时所有配置都集中在 editor.config 全局。新版本将做拆分:
- 在扩展 menu 时定义默认配置,不再全局定义
- 配置统一存储在
editor.getConfig().menuConf[key]
中,支持用户修改
// module 中扩展 menu 时,定义默认配置
// menu 代码中,可以通过 editor.getConfig().menuConf[key] 拿到
// 用户可以通过 editor.getConfig().menuConf[key] = {...} 修改某个 menu 的配置
{
key: 'color',
factory() {
return new ColorMenu('color', '文字颜色', '<svg>...</svg>')
},
config: {
colors: ['#000000', '#262626', '#595959', '#8c8c8c', '#bfbfbf', '#d9d9d9'],
},
}
扩展 plugin
很多功能需要用到插件功能,来重写 editor API ,例如:
- header – 末尾换行时,下一行插入
<p>
,而不是默认的 header - list – 末尾连续两次换行,跳出 list ,插入
<p>
- code-block – 末尾连续两次换行,跳出 code-block ,插入
<p>
- table – 单元格内换行;两个 table 如果紧挨着,中间插入空行。等
- 粘贴 – 处理粘贴文本之后,再插入文本
- 还有很多…(越复杂的功能,越需要 plugin 的加持)
下面是 header module 中的插件,比较简单
import { Editor, Transforms } from 'slate'
function withHeader<T extends Editor>(editor: T): T {
const { insertBreak } = editor
const newEditor = editor
// 重写 insertBreak - header 末尾回车时要插入 paragraph
newEditor.insertBreak = () => {
const [match] = Editor.nodes(newEditor, {
match: n => {
const { type = '' } = n
return type.startsWith('header') // 匹配 node.type 是 header 开头的 node
},
universal: true,
})
if (!match) {
// 未匹配到
insertBreak()
return
}
// 插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
Transforms.insertNodes(newEditor, p, { mode: 'highest' })
}
// 返回 editor ,重要!
return newEditor
}
后续计划
还有很多事情需要做,并整合到设计中。例如:
- 其他的基础功能
- 粘贴
- 上传
- 悬浮菜单、tooltip、右键菜单、modal
- 梳理用户配置
- 梳理 API
- 单元测试 / e2e 测试
- CI/CD
- i18n
- 写开发文档、用户使用文档
- ……
后续会先花 3 周左右时间完成基本功能,定稿技术方案和扩展形式。
其他的再慢慢补充。
而且,还要做大量的测试,我计划在发布之前,把当前 github issues 中积累的 3000+ 问题都在新版本测试一遍。这些 issues 都是积累的财富。
富文本编辑器是公认的天坑,那我至少先踩一踩这 3000+ 坑。
今天的文章基于 slate.js(不依赖 React)设计富文本编辑器分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/23041.html