我们项目使用的是 wangeditor,它是国内一个开发者开源的,功能基本足够,样式主流。
基础用法:
html
<script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
<script>
const { createEditor, createToolbar } = window.wangEditor
const editorConfig = {
placeholder: 'Type here...',
onChange(editor) {
const html = editor.getHtml()
console.log('editor content', html)
// 也可以同步到 <textarea>
}
}
const editor = createEditor({
selector: '#editor-container',
html: '<p><br></p>',
config: editorConfig,
mode: 'default', // or 'simple'
})
const toolbarConfig = {}
const toolbar = createToolbar({
editor,
selector: '#toolbar-container',
config: toolbarConfig,
mode: 'default', // or 'simple'
})
</script>
createEditor 源码
docs/dev.md 可看到准备工作: 了解 slate.js、了解 vdom 和 snabbdom.js、了解 lerna。
editor是入口位置,core实现基础的编辑器功能,其他文件夹是编辑器扩展的插件功能
createEditor主要逻辑是执行
coreCreateEditor
,coreCreateEditor
的代码位置 packages/core/src/create/create-editor.ts:
js
import { createEditor, Descendant } from 'slate'
export default function (option: Partial<ICreateOption>) {
const { selector = '', config = {}, content, html, plugins = [] } = option
// 创建实例 - 使用插件
let editor = withHistory(
withMaxLength(
withEmitter(withSelection(withContent(withConfig(withDOM(withEventData(createEditor()))))))
)
)
...
// 注册第三方插件
plugins.forEach(plugin => {
editor = plugin(editor)
})
editor.children = ...
if (selector) {
// 传入了 selector ,则创建 textarea DOM
const textarea = new TextArea(selector)
EDITOR_TO_TEXTAREA.set(editor, textarea)
TEXTAREA_TO_EDITOR.set(textarea, editor)
textarea.changeViewState() // 初始化时触发一次,以便能初始化 textarea DOM 和 selection
}
return editor
}
可以看出这里很重要的的几步:
- 创建editor实例,这里用到了
slate
的功能,withEventData...withSelection都是对editor实例属性的扩展; - 注册第三方插件,对应packages下面其他6个文件夹:基础模块、代码高亮、列表、table、上传图片、视频。后面继续解读。
- 创建实例TextArea,将editor对应的vnode挂载在textArea dom上
初次以及后面内容变动,调用textarea.changeViewState
,该方法主要执行 updateView(this, editor)
方法
updateView
代码位置packages/core/src/text-area/update-view.ts
- 1 首先对textarea dom预处理,将editor.children处理生成newVnode
- 2 通过
snabbdom 的 patch
方法将editor.children 的newVnode
更新到textareaElem
上
ts
function updateView(textarea: TextArea, editor: IDomEditor) {
const elemId = genElemId(textarea.id)
// 生成 newVnode
const newVnode = genRootVnode(elemId, readOnly)
const content = editor.children || []
newVnode.children = content.map((node, i) => {
let vnode = node2Vnode(node, i, editor, editor)
normalizeVnodeData(vnode) // 整理 vnode.data 以符合 snabbdom 的要求
return vnode
})
...
if (isFirstPatch) {
// 第一次 patch ,先生成 elem
const $textArea = genRootElem(elemId, readOnly)
$scroll.append($textArea)
textarea.$textArea = $textArea // 存储下编辑区域的 DOM 节点
textareaElem = $textArea[0]
// 再生成 patch 函数,并执行
const patchFn = genPatchFn()
patchFn(textareaElem, newVnode)
// 存储相关信息
IS_FIRST_PATCH.set(textarea, false) // 不再是第一次 patch
TEXTAREA_TO_PATCH_FN.set(textarea, patchFn) // 存储 patch 函数
} else {
// 不是第一次 patch
const curVnode = TEXTAREA_TO_VNODE.get(textarea)
const patchFn = TEXTAREA_TO_PATCH_FN.get(textarea)
if (curVnode == null || patchFn == null) return
textareaElem = curVnode.elm
patchFn(curVnode, newVnode)
}
textareaElem = getElementById(elemId)
...
EDITOR_TO_ELEMENT.set(editor, textareaElem) // 存储 editor -> elem 对应关系
NODE_TO_ELEMENT.set(editor, textareaElem)
ELEMENT_TO_NODE.set(textareaElem, editor)
TEXTAREA_TO_VNODE.set(textarea, newVnode) // 存储 vnode
}
node2Vnode
代码位置packages/core/src/render/node2Vnode.ts 主要功能是根据editor对象
生成对应的vnode,可以看出也是一个深度优先遍历来处理节点
ts
export function node2Vnode(node: Node, index: number, parent: Ancestor, editor: IDomEditor): VNode {
// 设置相关 weakMap 信息
NODE_TO_INDEX.set(node, index)
NODE_TO_PARENT.set(node, parent)
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
}
function renderElement(elemNode: SlateElement, editor: IDomEditor): VNode {
...
const { type, children = [] } = elemNode
let childrenVnode
if (isVoid) {
childrenVnode = null // void 节点 render elem 时不传入 children
} else {
childrenVnode = children.map((child: Node, index: number) => {
return node2Vnode(child, index, elemNode, editor)
})
}
// 创建 vnode
let vnode = renderElem(elemNode, childrenVnode, editor)
...
return vnode
}
createToolbar源码
菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域的内容,在阅读源码前,我们大概可以推测它主要思路就是 通过
editor的api
对其children node更改
coreCreateToolbar
- createToolbar的主要逻辑是
coreCreateToolbar
coreCreateToolbar
主要逻辑是new Toolbar()
实例化 Toolbar
主要就是生成 菜单元素并挂载在 传入的selector节点
,此外一个重要功能就是*注册菜单功能,我们主要看看注册单个 toolbarItem的逻辑- 最终逻辑在
BaseButton
,这也证实我们推测,渲染的按钮绑定的事件逻辑menu.exec(editor, value)
ts
// ----Toolbar--------------
class Toolbar {
$box: Dom7Array
private readonly $toolbar: Dom7Array = $(`<div class="w-e-bar w-e-bar-show w-e-toolbar"></div>`)
private menus: { [key: string]: MenuType } = {}
private toolbarItems: IBarItem[] = []
private config: Partial<IToolbarConfig> = {}
constructor(boxSelector: string | DOMElement, config: Partial<IToolbarConfig>) {
this.config = config
this.$box = $box
const $toolbar = this.$toolbar
$toolbar.on('mousedown', e => e.preventDefault(), { passive: false }) // 防止点击失
$box.append($toolbar)
// 异步,否则拿不到 editor 实例
promiseResolveThen(() => {
// 注册 items
this.registerItems()
// 创建完,先模拟一次 onchange
this.changeToolbarState()
// 监听 editor onchange
const editor = this.getEditorInstance()
editor.on('change', this.changeToolbarState)
})
}
private registerSingleItem(key: string, container: GroupButton | Toolbar) {
const editor = this.getEditorInstance()
const { menus } = this
let menu = menus[key]
if (menu == null) {
// 缓存中没有,则创建
const factory = MENU_ITEM_FACTORIES[key]
menu = factory()
menus[key] = menu
}
const toolbarItem = createBarItem(key, menu, inGroup)
this.toolbarItems.push(toolbarItem)
// 保存 toolbarItem 和 editor 的关系
BAR_ITEM_TO_EDITOR.set(toolbarItem, editor)
...
toolbar.$toolbar.append(toolbarItem.$elem)
}
...
}
// ----createBarItem--------------
export function createBarItem(key: string, menu: MenuType, inGroup: boolean = false): IBarItem {
if (tag === 'button') {
const { showDropPanel, showModal } = menu
if (showDropPanel) {
barItem = new DropPanelButton(key, menu as IDropPanelMenu, inGroup)
} else if (showModal) {
barItem = new ModalButton(key, menu as IModalMenu, inGroup)
} else {
barItem = new SimpleButton(key, menu, inGroup)
}
}
if (tag === 'select') {
barItem = new Select(key, menu as ISelectMenu, inGroup)
}
return barItem
}
//----SimpleButton--------------
class SimpleButton extends BaseButton {
constructor(key: string, menu: IButtonMenu, inGroup = false) {
super(key, menu, inGroup)
}
onButtonClick() {
// menu.exec 已经在 BaseButton 实现了
// 所以,此处不用做任何逻辑
}
}
//----BaseButton--------------
abstract class BaseButton implements IBarItem {
readonly $elem: Dom7Array = $(`<div class="w-e-bar-item"></div>`)
protected readonly $button: Dom7Array = $(`<button type="button"></button>`)
menu: IButtonMenu | IDropPanelMenu | IModalMenu
private disabled = false
constructor(key: string, menu: IButtonMenu | IDropPanelMenu | IModalMenu, inGroup = false) {
this.menu = menu
const { tag, width } = menu
// 初始化 dom
const { title, hotkey = '', iconSvg = '' } = menu
const { $button } = this
if (iconSvg) {
const $svg = $(iconSvg)
clearSvgStyle($svg) // 清理 svg 样式(扩展的菜单,svg 是不可控的,所以要清理一下)
$button.append($svg)
} else {
$button.text(title)// 无 icon 则显示 title
}
addTooltip($button, iconSvg, title, hotkey, inGroup) // 设置 tooltip
if (width) {
$button.css('width', `${width}px`)
}
$button.attr('data-menu-key', key) // menu key
this.$elem.append($button)
// 异步绑定事件
promiseResolveThen(() => this.init())
}
private init() {
this.setActive()
this.setDisabled()
this.$button.on('click', e => {
e.preventDefault()
const editor = getEditorInstance(this)
editor.hidePanelOrModal() // 隐藏当前的各种 panel
if (this.disabled) return
this.exec() // 执行 menu.exec
this.onButtonClick() // 执行其他的逻辑
})
}
private exec() {
const editor = getEditorInstance(this)
const menu = this.menu
const value = menu.getValue(editor)
menu.exec(editor, value)
}
// 交给子类去扩展
abstract onButtonClick(): void
private setActive() {
const editor = getEditorInstance(this)
const { $button } = this
const active = this.menu.isActive(editor)
const className = 'active'
if (active) {
// 设置为 active
$button.addClass(className)
} else {
// 取消 active
$button.removeClass(className)
}
}
private setDisabled() {...}
changeMenuState() {
this.setActive()
this.setDisabled()
}
}
registerModule
通过前面的 coreCreateToolbar
代码分析,我们知道主要是取 MENU_ITEM_FACTORIES[key]
的数据进行menus的初始化。在项目的入口位置import './register-builtin-modules/index'
执行registerModule,注册内置模块。
ts
//-------------register-builtin-modules/index----------
basicModules.forEach(module => registerModule(module))
registerModule(wangEditorListModule)
registerModule(wangEditorTableModule)
registerModule(wangEditorVideoModule)
registerModule(wangEditorUploadImageModule)
registerModule(wangEditorCodeHighlightModule)
function registerModule(module: Partial<IModuleConf>) {
const {
menus,
renderElems,
renderStyle,
elemsToHtml,
styleToHtml,
preParseHtml,
parseElemsHtml,
parseStyleHtml,
editorPlugin,
} = module
if (menus) {
menus.forEach(menu => Boot.registerMenu(menu))
}
if (renderElems) {
renderElems.forEach(renderElemConf => Boot.registerRenderElem(renderElemConf))
}
if (renderStyle) {
Boot.registerRenderStyle(renderStyle)
}
if (elemsToHtml) {
elemsToHtml.forEach(elemToHtmlConf => Boot.registerElemToHtml(elemToHtmlConf))
}
if (styleToHtml) {
Boot.registerStyleToHtml(styleToHtml)
}
if (preParseHtml) {
preParseHtml.forEach(conf => Boot.registerPreParseHtml(conf))
}
if (parseElemsHtml) {
parseElemsHtml.forEach(parseElemHtmlConf => Boot.registerParseElemHtml(parseElemHtmlConf))
}
if (parseStyleHtml) {
Boot.registerParseStyleHtml(parseStyleHtml)
}
if (editorPlugin) {
Boot.registerPlugin(editorPlugin)
}
}
export function registerMenu(
registerMenuConf: IRegisterMenuConf,
customConfig?: { [key: string]: any }
) {
const { key, factory, config } = registerMenuConf
const newConfig = { ...config, ...(customConfig || {}) }
MENU_ITEM_FACTORIES[key] = factory
// 将 config 保存到全局
registerGlobalMenuConf(key, newConfig)
}
module格式
module格式如下 Partial<IModuleConf>
,取IModuleConf的部分属性,分为5类作用
ts
export interface IModuleConf {
// 1、注册菜单
menus: Array<IRegisterMenuConf>
// 2、渲染 modal -> view
renderStyle: RenderStyleFnType
renderElems: Array<IRenderElemConf>
// 3、获取html的时候的处理
styleToHtml: styleToHtmlFnType
elemsToHtml: Array<IElemToHtmlConf>
// 4、预处理html格式,parse html
preParseHtml: Array<IPreParseHtmlConf>
parseStyleHtml: ParseStyleHtmlFnType
parseElemsHtml: Array<IParseElemHtmlConf>
// 5、注册插件
editorPlugin: <T extends IDomEditor>(editor: T) => T
}
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更改
欢迎关注我的前端自检清单,我和你一起成长