我们项目使用的是 wangeditor,它是国内一个开发者开源的,功能基本足够,样式主流。
- 基础用法:
1<script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
2<script>
3const { createEditor, createToolbar } = window.wangEditor
4
5const editorConfig = {
6 placeholder: 'Type here...',
7 onChange(editor) {
8 const html = editor.getHtml()
9 console.log('editor content', html)
10
11 }
12}
13
14const editor = createEditor({
15 selector: '#editor-container',
16 html: '<p><br></p>',
17 config: editorConfig,
18 mode: 'default',
19})
20
21const toolbarConfig = {}
22
23const toolbar = createToolbar({
24 editor,
25 selector: '#toolbar-container',
26 config: toolbarConfig,
27 mode: 'default',
28})
29</script>
- 源码结构:
editor是入口位置,core实现基础的编辑器功能,其他文件夹是编辑器扩展的插件功能
createEditor主要逻辑是执行
coreCreateEditor
,coreCreateEditor
的代码位置 packages/core/src/create/create-editor.ts:
1import { createEditor, Descendant } from 'slate'
2
3export default function (option: Partial<ICreateOption>) {
4 const { selector = '', config = {}, content, html, plugins = [] } = option
5
6
7 let editor = withHistory(
8 withMaxLength(
9 withEmitter(withSelection(withContent(withConfig(withDOM(withEventData(createEditor()))))))
10 )
11 )
12 ...
13
14 plugins.forEach(plugin => {
15 editor = plugin(editor)
16 })
17 editor.children = ...
18
19 if (selector) {
20
21 const textarea = new TextArea(selector)
22 EDITOR_TO_TEXTAREA.set(editor, textarea)
23 TEXTAREA_TO_EDITOR.set(textarea, editor)
24 textarea.changeViewState()
25 }
26 return editor
27}
可以看出这里很重要的的几步:
-
创建editor实例,这里用到了
slate
的功能,withEventData…withSelection都是对editor实例属性的扩展; -
注册第三方插件,对应packages下面其他6个文件夹:基础模块、代码高亮、列表、table、上传图片、视频。后面继续解读。
-
创建实例TextArea,将editor对应的vnode挂载在textArea dom上
初次以及后面内容变动,调用
textarea.changeViewState
,该方法主要执行updateView(this, editor)
方法
updateView
updateView
代码位置packages/core/src/text-area/update-view.ts
- 3.1 首先对textarea dom预处理,将editor.children处理生成newVnode
- 3.2 通过
snabbdom 的 patch
方法将editor.children 的newVnode
更新到textareaElem
上
1function updateView(textarea: TextArea, editor: IDomEditor) {
2
3 const elemId = genElemId(textarea.id)
4
5 const newVnode = genRootVnode(elemId, readOnly)
6 const content = editor.children || []
7 newVnode.children = content.map((node, i) => {
8 let vnode = node2Vnode(node, i, editor, editor)
9 normalizeVnodeData(vnode)
10 return vnode
11 })
12
13 ...
14 if (isFirstPatch) {
15
16 const $textArea = genRootElem(elemId, readOnly)
17 $scroll.append($textArea)
18 textarea.$textArea = $textArea
19 textareaElem = $textArea[0]
20
21
22 const patchFn = genPatchFn()
23 patchFn(textareaElem, newVnode)
24
25
26 IS_FIRST_PATCH.set(textarea, false)
27 TEXTAREA_TO_PATCH_FN.set(textarea, patchFn)
28 } else {
29
30 const curVnode = TEXTAREA_TO_VNODE.get(textarea)
31 const patchFn = TEXTAREA_TO_PATCH_FN.get(textarea)
32 if (curVnode == null || patchFn == null) return
33 textareaElem = curVnode.elm
34
35 patchFn(curVnode, newVnode)
36 }
37
38
39 textareaElem = getElementById(elemId)
40 ...
41
42 EDITOR_TO_ELEMENT.set(editor, textareaElem)
43 NODE_TO_ELEMENT.set(editor, textareaElem)
44 ELEMENT_TO_NODE.set(textareaElem, editor)
45 TEXTAREA_TO_VNODE.set(textarea, newVnode)
46}
node2Vnode
node2Vnode
代码位置packages/core/src/render/node2Vnode.ts 主要功能是根据editor对象
生成对应的vnode,可以看出也是一个深度优先遍历来处理节点
1export function node2Vnode(node: Node, index: number, parent: Ancestor, editor: IDomEditor): VNode {
2
3 NODE_TO_INDEX.set(node, index)
4 NODE_TO_PARENT.set(node, parent)
5
6 let vnode: VNode
7 if (Element.isElement(node)) {
8
9 vnode = renderElement(node as Element, editor)
10 } else {
11
12 vnode = renderText(node as Text, parent, editor)
13 }
14
15 return vnode
16}
17
18function renderElement(elemNode: SlateElement, editor: IDomEditor): VNode {
19 ...
20 const { type, children = [] } = elemNode
21 let childrenVnode
22 if (isVoid) {
23 childrenVnode = null
24 } else {
25 childrenVnode = children.map((child: Node, index: number) => {
26 return node2Vnode(child, index, elemNode, editor)
27 })
28 }
29
30
31 let vnode = renderElem(elemNode, childrenVnode, editor)
32 ...
33 return vnode
34}
菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域的内容,在阅读源码前,我们大概可以推测它主要思路就是 通过
editor的api
对其children node更改
coreCreateToolbar
- createToolbar的主要逻辑是
coreCreateToolbar
coreCreateToolbar
主要逻辑是new Toolbar()
实例化 Toolbar
主要就是生成 菜单元素并挂载在 传入的selector节点
,此外一个重要功能就是*注册菜单功能,我们主要看看注册单个 toolbarItem的逻辑- 最终逻辑在
BaseButton
,这也证实我们推测,渲染的按钮绑定的事件逻辑menu.exec(editor, value)
1
2 class Toolbar {
3 $box: Dom7Array
4 private readonly $toolbar: Dom7Array = $(`<div class="w-e-bar w-e-bar-show w-e-toolbar"></div>`)
5 private menus: { [key: string]: MenuType } = {}
6 private toolbarItems: IBarItem[] = []
7 private config: Partial<IToolbarConfig> = {}
8
9 constructor(boxSelector: string | DOMElement, config: Partial<IToolbarConfig>) {
10 this.config = config
11 this.$box = $box
12 const $toolbar = this.$toolbar
13 $toolbar.on('mousedown', e => e.preventDefault(), { passive: false })
14 $box.append($toolbar)
15
16 promiseResolveThen(() => {
17
18 this.registerItems()
19
20
21 this.changeToolbarState()
22
23
24 const editor = this.getEditorInstance()
25 editor.on('change', this.changeToolbarState)
26 })
27 }
28
29 private registerSingleItem(key: string, container: GroupButton | Toolbar) {
30 const editor = this.getEditorInstance()
31 const { menus } = this
32 let menu = menus[key]
33
34 if (menu == null) {
35
36 const factory = MENU_ITEM_FACTORIES[key]
37 menu = factory()
38 menus[key] = menu
39 }
40
41 const toolbarItem = createBarItem(key, menu, inGroup)
42 this.toolbarItems.push(toolbarItem)
43
44
45 BAR_ITEM_TO_EDITOR.set(toolbarItem, editor)
46
47 ...
48 toolbar.$toolbar.append(toolbarItem.$elem)
49 }
50
51 ...
52 }
53
54 export function createBarItem(key: string, menu: MenuType, inGroup: boolean = false): IBarItem {
55 if (tag === 'button') {
56 const { showDropPanel, showModal } = menu
57 if (showDropPanel) {
58 barItem = new DropPanelButton(key, menu as IDropPanelMenu, inGroup)
59 } else if (showModal) {
60 barItem = new ModalButton(key, menu as IModalMenu, inGroup)
61 } else {
62 barItem = new SimpleButton(key, menu, inGroup)
63 }
64 }
65 if (tag === 'select') {
66 barItem = new Select(key, menu as ISelectMenu, inGroup)
67 }
68 return barItem
69}
70
71
72
73class SimpleButton extends BaseButton {
74 constructor(key: string, menu: IButtonMenu, inGroup = false) {
75 super(key, menu, inGroup)
76 }
77 onButtonClick() {
78
79
80 }
81}
82
83
84abstract class BaseButton implements IBarItem {
85 readonly $elem: Dom7Array = $(`<div class="w-e-bar-item"></div>`)
86 protected readonly $button: Dom7Array = $(`<button type="button"></button>`)
87 menu: IButtonMenu | IDropPanelMenu | IModalMenu
88 private disabled = false
89
90 constructor(key: string, menu: IButtonMenu | IDropPanelMenu | IModalMenu, inGroup = false) {
91 this.menu = menu
92 const { tag, width } = menu
93
94
95 const { title, hotkey = '', iconSvg = '' } = menu
96 const { $button } = this
97 if (iconSvg) {
98 const $svg = $(iconSvg)
99 clearSvgStyle($svg)
100 $button.append($svg)
101 } else {
102 $button.text(title)
103 }
104 addTooltip($button, iconSvg, title, hotkey, inGroup)
105 if (width) {
106 $button.css('width', `${width}px`)
107 }
108 $button.attr('data-menu-key', key)
109 this.$elem.append($button)
110
111
112 promiseResolveThen(() => this.init())
113 }
114
115 private init() {
116 this.setActive()
117 this.setDisabled()
118
119 this.$button.on('click', e => {
120 e.preventDefault()
121 const editor = getEditorInstance(this)
122 editor.hidePanelOrModal()
123 if (this.disabled) return
124
125 this.exec()
126 this.onButtonClick()
127 })
128 }
129
130
131 private exec() {
132 const editor = getEditorInstance(this)
133 const menu = this.menu
134 const value = menu.getValue(editor)
135 menu.exec(editor, value)
136 }
137
138
139 abstract onButtonClick(): void
140
141 private setActive() {
142 const editor = getEditorInstance(this)
143 const { $button } = this
144 const active = this.menu.isActive(editor)
145
146 const className = 'active'
147 if (active) {
148
149 $button.addClass(className)
150 } else {
151
152 $button.removeClass(className)
153 }
154 }
155 private setDisabled() {...}
156 changeMenuState() {
157 this.setActive()
158 this.setDisabled()
159 }
160}
registerModule
通过前面的 coreCreateToolbar
代码分析,我们知道主要是取 MENU_ITEM_FACTORIES[key]
的数据进行menus的初始化。在项目的入口位置import './register-builtin-modules/index'
执行registerModule,注册内置模块。
1
2basicModules.forEach(module => registerModule(module))
3registerModule(wangEditorListModule)
4registerModule(wangEditorTableModule)
5registerModule(wangEditorVideoModule)
6registerModule(wangEditorUploadImageModule)
7registerModule(wangEditorCodeHighlightModule)
8
9
10function registerModule(module: Partial<IModuleConf>) {
11 const {
12 menus,
13 renderElems,
14 renderStyle,
15 elemsToHtml,
16 styleToHtml,
17 preParseHtml,
18 parseElemsHtml,
19 parseStyleHtml,
20 editorPlugin,
21 } = module
22
23 if (menus) {
24 menus.forEach(menu => Boot.registerMenu(menu))
25 }
26 if (renderElems) {
27 renderElems.forEach(renderElemConf => Boot.registerRenderElem(renderElemConf))
28 }
29 if (renderStyle) {
30 Boot.registerRenderStyle(renderStyle)
31 }
32 if (elemsToHtml) {
33 elemsToHtml.forEach(elemToHtmlConf => Boot.registerElemToHtml(elemToHtmlConf))
34 }
35 if (styleToHtml) {
36 Boot.registerStyleToHtml(styleToHtml)
37 }
38 if (preParseHtml) {
39 preParseHtml.forEach(conf => Boot.registerPreParseHtml(conf))
40 }
41 if (parseElemsHtml) {
42 parseElemsHtml.forEach(parseElemHtmlConf => Boot.registerParseElemHtml(parseElemHtmlConf))
43 }
44 if (parseStyleHtml) {
45 Boot.registerParseStyleHtml(parseStyleHtml)
46 }
47 if (editorPlugin) {
48 Boot.registerPlugin(editorPlugin)
49 }
50}
51
52
53export function registerMenu(
54 registerMenuConf: IRegisterMenuConf,
55 customConfig?: { [key: string]: any }
56) {
57 const { key, factory, config } = registerMenuConf
58 const newConfig = { ...config, ...(customConfig || {}) }
59
60 MENU_ITEM_FACTORIES[key] = factory
61
62
63 registerGlobalMenuConf(key, newConfig)
64}
module格式
module格式如下 Partial<IModuleConf>
,取IModuleConf的部分属性,分为5类作用
1export interface IModuleConf {
2
3 menus: Array<IRegisterMenuConf>
4
5
6 renderStyle: RenderStyleFnType
7 renderElems: Array<IRenderElemConf>
8
9
10 styleToHtml: styleToHtmlFnType
11 elemsToHtml: Array<IElemToHtmlConf>
12
13
14 preParseHtml: Array<IPreParseHtmlConf>
15 parseStyleHtml: ParseStyleHtmlFnType
16 parseElemsHtml: Array<IParseElemHtmlConf>
17
18
19 editorPlugin: <T extends IDomEditor>(editor: T) => T
20}
module menus格式
富文本编辑器 系列文章:
通过对编辑器源码的解读,我学会了很多新思想,下面总结一下
- 文本标签
input 和 textarea
它们都不能设置丰富的样式,于是我们采用contenteditable
属性的编辑框,常规做法是结合document.execCommand
命令对编辑区域的元素进行设置,这就类似于我们初代的前端代码原生js/jquery,更改页面效果通过对真实dom的增删改查;而wangeditor
和slate-react
采用了一个新的思想,这就类似我们用 react/vue等框架开发页面,通过数据驱动的思想更改页面的元素。 - 分析
wangeditor
和slate-react
源码我们可以看出两者功能类似,都是将slate->createEditor()
生成的editor对象转化为vnode,然后将虚拟dom挂载在带有contenteditable
属性的节点上;slate-react
是基于react,wangeditor
是通过snabbdom.js
,做到了与框架无关 - 菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域的内容,它主要思路就是 通过
editor的api
对其children node更改
欢迎关注我的前端自检清单,我和你一起成长