首页/文章/富文本

wangeditor源码分析

2024-03-05
13265 分钟
...

wangeditor官方文档

我们项目使用的是 wangeditor,它是国内一个开发者开源的,功能基本足够,样式主流。

  • 基础用法:
<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)
      
    }
}

const editor = createEditor({
    selector: '#editor-container',
    html: '<p><br></p>',
    config: editorConfig,
    mode: 'default', 
})

const toolbarConfig = {}

const toolbar = createToolbar({
    editor,
    selector: '#toolbar-container',
    config: toolbarConfig,
    mode: 'default', 
})
</script>
  • 源码结构:

editor是入口位置,core实现基础的编辑器功能,其他文件夹是编辑器扩展的插件功能

createEditor主要逻辑是执行 coreCreateEditorcoreCreateEditor的代码位置 packages/core/src/create/create-editor.ts:

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) {
    
    const textarea = new TextArea(selector)
    EDITOR_TO_TEXTAREA.set(editor, textarea)
    TEXTAREA_TO_EDITOR.set(textarea, editor)
    textarea.changeViewState() 
  } 
  return editor
}

可以看出这里很重要的的几步:

  1. 创建editor实例,这里用到了slate的功能,withEventData...withSelection都是对editor实例属性的扩展;

  2. 注册第三方插件,对应packages下面其他6个文件夹:基础模块、代码高亮、列表、table、上传图片、视频。后面继续解读。

  3. 创建实例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
function updateView(textarea: TextArea, editor: IDomEditor) {

  const elemId = genElemId(textarea.id)
  
  const newVnode = genRootVnode(elemId, readOnly)
  const content = editor.children || []
  newVnode.children = content.map((node, i) => {
    let vnode = node2Vnode(node, i, editor, editor)
    normalizeVnodeData(vnode) 
    return vnode
  })

  ...
  if (isFirstPatch) {
    
    const $textArea = genRootElem(elemId, readOnly)
    $scroll.append($textArea)
    textarea.$textArea = $textArea 
    textareaElem = $textArea[0]

    
    const patchFn = genPatchFn()
    patchFn(textareaElem, newVnode)

    
    IS_FIRST_PATCH.set(textarea, false) 
    TEXTAREA_TO_PATCH_FN.set(textarea, patchFn) 
  } else {
    
    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) 
  NODE_TO_ELEMENT.set(editor, textareaElem)
  ELEMENT_TO_NODE.set(textareaElem, editor)
  TEXTAREA_TO_VNODE.set(textarea, newVnode) 
}

node2Vnode

node2Vnode 代码位置packages/core/src/render/node2Vnode.ts 主要功能是根据 editor对象生成对应的vnode,可以看出也是一个深度优先遍历来处理节点

export function node2Vnode(node: Node, index: number, parent: Ancestor, editor: IDomEditor): VNode {
  
  NODE_TO_INDEX.set(node, index)
  NODE_TO_PARENT.set(node, parent)

  let vnode: VNode
  if (Element.isElement(node)) {
    
    vnode = renderElement(node as Element, editor)
  } else {
    
    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 
  } else {
    childrenVnode = children.map((child: Node, index: number) => {
      return node2Vnode(child, index, elemNode, editor)
    })
  }

  
  let vnode = renderElem(elemNode, childrenVnode, editor)
  ...
  return vnode
}

菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域的内容,在阅读源码前,我们大概可以推测它主要思路就是 通过 editor的api 对其children node更改

coreCreateToolbar

  • createToolbar的主要逻辑是coreCreateToolbar
  • coreCreateToolbar主要逻辑是 new Toolbar()
  • 实例化 Toolbar主要就是生成 菜单元素并挂载在 传入的 selector节点,此外一个重要功能就是*注册菜单功能,我们主要看看注册单个 toolbarItem的逻辑
  • 最终逻辑在 BaseButton,这也证实我们推测,渲染的按钮绑定的事件逻辑 menu.exec(editor, value)
 
  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)
        
        promiseResolveThen(() => {
          
          this.registerItems()

          
          this.changeToolbarState()

          
          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)

        
        BAR_ITEM_TO_EDITOR.set(toolbarItem, editor)

       ...
        toolbar.$toolbar.append(toolbarItem.$elem)
      }
      
      ...
  }
  
  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
}



class SimpleButton extends BaseButton {
  constructor(key: string, menu: IButtonMenu, inGroup = false) {
    super(key, menu, inGroup)
  }
  onButtonClick() {
    
    
  }
}


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

    
    const { title, hotkey = '', iconSvg = '' } = menu
    const { $button } = this
    if (iconSvg) {
      const $svg = $(iconSvg)
      clearSvgStyle($svg) 
      $button.append($svg)
    } else {
      $button.text(title)
    }
    addTooltip($button, iconSvg, title, hotkey, inGroup) 
    if (width) {
      $button.css('width', `${width}px`)
    }
    $button.attr('data-menu-key', 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() 
      if (this.disabled) return

      this.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) {
      
      $button.addClass(className)
    } else {
      
      $button.removeClass(className)
    }
  }
  private setDisabled() {...}
  changeMenuState() {
    this.setActive()
    this.setDisabled()
  }
}

registerModule

通过前面的 coreCreateToolbar 代码分析,我们知道主要是取 MENU_ITEM_FACTORIES[key]的数据进行menus的初始化。在项目的入口位置import './register-builtin-modules/index'执行registerModule,注册内置模块。


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

  
  registerGlobalMenuConf(key, newConfig)
}

module格式

module格式如下 Partial<IModuleConf>,取IModuleConf的部分属性,分为5类作用

export interface IModuleConf {
  
  menus: Array<IRegisterMenuConf>

  
  renderStyle: RenderStyleFnType
  renderElems: Array<IRenderElemConf>

  
  styleToHtml: styleToHtmlFnType
  elemsToHtml: Array<IElemToHtmlConf>

  
  preParseHtml: Array<IPreParseHtmlConf>
  parseStyleHtml: ParseStyleHtmlFnType
  parseElemsHtml: Array<IParseElemHtmlConf>

  
  editorPlugin: <T extends IDomEditor>(editor: T) => T
}

module menus格式

富文本编辑器 系列文章:

  1. 富文本editor
  2. wangeditor源码分析
  3. slate源码解读

通过对编辑器源码的解读,我学会了很多新思想,下面总结一下

  1. 文本标签 input 和 textarea它们都不能设置丰富的样式,于是我们采用 contenteditable 属性的编辑框,常规做法是结合 document.execCommand 命令对编辑区域的元素进行设置,这就类似于我们初代的前端代码原生js/jquery,更改页面效果通过对真实dom的增删改查;而wangeditorslate-react采用了一个新的思想,这就类似我们用 react/vue等框架开发页面,通过数据驱动的思想更改页面的元素。
  2. 分析 wangeditorslate-react源码我们可以看出两者功能类似,都是将 slate->createEditor()生成的editor对象转化为vnode,然后将虚拟dom挂载在带有 contenteditable 属性的节点上;slate-react是基于react,wangeditor是通过snabbdom.js,做到了与框架无关
  3. 菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域的内容,它主要思路就是 通过 editor的api 对其children node更改

欢迎关注我的前端自检清单,我和你一起成长

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
富文本2025-01-04
Monaco Editor,代码编辑器神器
本文介绍了 Monaco Editor 这款微软开源的基于浏览器的代码编辑器,它功能强大,包括语法高亮、代码补全等,具有轻量、可定制、跨平台等特点。文中阐述了其应用场景、使用方法、基本概念等,还列举了类似项目并给出项目地址,适合多种开发需求。 关联问题: Monaco支持哪些语言 如何定制Monaco 怎样优化界面效果 Monaco Editor是微软开源的一款基于浏览器的代码编辑器,它源自于Visual Studio Code(简称V...
学习
富文本2024-09-02
富文本格式粘贴的问题
回到最初的问题,利用富文本编辑器,编写邮件模板,或者说,提供一个编辑器,用来编写邮件。如果说编写邮件而已,大可以在 word 或者其他编辑器里写好内容,但这里的一个特点就是 占位符 。所以说,这是要保持邮件编辑器的功能,然后还能使用占位符。 前面也是利用了 实现了 占位符 ,且具有不可编辑的特点。另外也具有了编辑器的基础功能,如文字大小、颜色、背景、居中、缩进等等。在开发中,只关注于编辑器本身,即这个编辑器能编辑就可以了,忽略了 这个功...
学习
富文本2024-05-09
vue3集成tinymce
简介 看了一圈富文本编辑器,发现 和 比较符合自己的需求,功能上 更加丰富、简单一些。 吐槽 吐槽一下 的官网,给了 这个组件包,但是没想到就只是简单包装了一下(期初以为下载完这个包,引入vue组件内就完成了!),没想到如果只是单独引入这个包,是需要 的,而且是云托管方式去下载 ,导致反复看官网折腾了好久! 自托管集成方式 有两种方式,一种是使用包管理工具、另一种是zip包。 ,因为 还是挺大的,开发和打包过程影响编译时间。 以下面技术...
学习
富文本2024-05-08
新一代富文本编辑器框架lexical入门
是一款 基于 开发的网页端文本编辑框架,具备高拓展性架构,以高可靠性、易用性以及性能表现为核心设计思想。本身不与任何框架绑定,可独立于 、 使用(不过由于 与 的亲和性, 有 版)。使用者可在其基础上建立属于自己的独一无二的文本编辑器 目前在 内部,每天 通过 、 、 、 、 等产品服务千百万用户,稳定性及可靠性值得信赖 的核心包(上图左侧部分)只有22kb,其余能力以 形式提供。框架支持延迟加载, 可以在用户真正操作编辑器的时候再加载...
学习
富文本2024-05-06
再遇协同编辑:Yjs + Quill,文档协同编辑竟如此简单
通过上一篇文章 ,相信各位小伙伴对 OT 和 CRDT 协同算法有了一定的了解,并且对 Yjs 有了初步的认识,本文主要教会大家如何使用 Yjs 来实现 Quill 的协同编辑,以及针对 Yjs 的一些重要概念进行讲解。 因为协作需要服务端的支持,所有没有 demo,具体实现请 。 Yjs 介绍 官方介绍: 用于构建 Google Docs 和 Figma 等协作应用程序的模块化构建块 。 基于 CRDT ,帮助实现高性能的协作应用程序...
学习
富文本2024-04-25
Vue使用wangEditor富文本编辑器报错(ot find a descendant at path [0,1] in node: {“children“:[{“type“:“paragraph)_cannot find a descendant at path [0,1] in node
答案 时渲染dom报错问题: 每题的答案 修改文字样式后切换报错 解决办法: 在动态切换答案回显的同时删除 wangEditor 的实例然后异步重新创建一遍进行回显 代码片段:
学习

评论

请登录后发表评论

去登录
加载评论中...

目录