阅读 2661

基于 slate.js(不依赖 React)设计富文本编辑器

背景和现状

wangEditor 正在设计新版本,力争做一个更加稳定、简洁的开源富文本编辑器。

  • 弃用 document.execCommand ,分离 view 和 model
  • 未来可支持协同编辑
  • 充分考虑扩展性和插件机制,以便于扩展更加复杂的功能
  • 程序员的技术追求,一直走在折腾自己的道路上~

我此前已经做过一些工作

  • 从 0 开发一个 demo ,根据实践的问题去调研 slate quill proseMirror ,已记录到这篇文章
  • 尝试使用 slate (不依赖 React)做 demo
  • 确定以 slate 为内核(不依赖 React),开始尝试设计新版本(WIP 源码未开放)

虽然已经实现部分功能,但目前还处于技术方案设计过程中,API 和代码结构还会继续调整。

image.png

为何要基于 slate.js ?

最初也想从 0 开始自研内核,后来慢慢改变了主意。特别是当看到 proseMirror 那一大堆代码之后。

image.png

为何不是自研内核?

  • 我们的核心目的,是做出一款稳定、易用、高扩展性的开源产品,并不是搞极客精神和造轮子。
  • 自研成本非常高,耗时长,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

image.png

底层依赖

  • 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 来实现具体的功能

image.png

使用 beforeinput 劫持用户输入

beforeinput 是一个比较新的 DOM 事件,去年还没有得到普遍支持。当前看来,主流浏览器已经支持了,特别是 FireFox 87 发布之后,参考 caniuse
对于还不支持的浏览器,可以用 keydown/keypress 来兼容,虽然会有一些影响,但好在用户占比不大。 (PS:新版本不再支持 IE11)

image.png

监听 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事件。这样就可以做到相互同步。

image.png

updateView 同步视图

editor onChange 时会触发更新视图,以保证 view 和 model 实时同步。分为两步:

  • 根据 model 生成 vnode
  • patch vnode

第二步很简单,我们利用 snabbdom.js 来做 vdom 渲染。vue 2.x 用的这个库,老牌的,稳定性好一些。 而且,它通过简单配置即可支持 jsx ,写起来非常非常方便。

关键在于第一步,生成 vnode 。 下面代码经过了简化,便于阅读,我们将逻辑拆分为两段:renderElementrenderText

/**
 * 根据 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
  
  // 后续还可能继续扩展其他能力,但尽量保证简洁、易读
}
复制代码

通过以上定义,可以支持如下菜单形式。其他的还在设计开发中。

image.png

editor API 和 plugins

参考 slate-react 源码,定义了一些全局 command ,在渲染 DOM 时很有用。

image.png

封装了一个 slate 插件,增加/重写 API

image.png

这个插件是 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

如加粗、下划线等

image.png

简单解释一下:

  • getValue 函数确定当前的状态,如是否加粗。
  • isDisabled 判断当前 menu 是否可用,如在代码块中,bold 不可用。
  • exec 即 menu 按钮点击时执行的方法。注意 tag = 'button' 是 button 类型的。

select menu

如设置标题,它需要定义 options 。

image.png

showDropPanel

如设置颜色,需要 dropPanel 。则该 menu 需要一个 getPanelContentElem 以定义 dropPanel 的内容 DOM 。

image.png

目前只有这三种类型,后续还可能扩展其他类型。

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+ 坑。

文章分类
前端
文章标签