富文本编辑器 wangEditor5 多级列表 - 设计与实现

3,243 阅读5分钟

一切美好的事物,都是简单的

前言

开源富文本编辑器 wangEditor5v5.1.19 版本开始,支持多级列表,可在 demo 中尝试操作。

  • 在每个 item 前面按 tab 键,增加层级。
  • 在每个 item 前面按 删除 键,减少层级。直至变成纯文本。

image.png

此前只有单级列表

wangEditor4 和 wangEditor5 之前的版本,只能支持单级列表。在各个方面都有很多问题。

第一,无法满足多级列表的需求,包括我自己写文章时,也经常会用到多级列表。

第二,复制粘贴时会让用户很迷惑,明明复制了一个多级列表,但粘贴到编辑器却是单级列表。

第三,虽然是单级列表,但却有很多 bug 。例如选中部分 list 进行复制粘贴,例如一些回车、删除操作。
本次重构就顺带修复了将近 10 个 bug 。

实现多级列表的难处

之前想升级到多级列表,但一直有一个难点卡着。

v5 此前对于列表的数据结构是这样的:(其实 v4 也是这样,只不过是 HTML 格式)

{
    type: 'bulleted-list',
    children: [
        { type: 'list-item', children: [{ text: 'a' }] },
        { type: 'list-item', children: [{ text: 'b' }] }
    ]
}

这种数据结构,如果升级到多级列表,就会变的层级非常多。如下:

{
    type: 'bulleted-list', // 第一层
    children: [
        { type: 'list-item', children: [{ text: 'a' }] },
        {
            type: 'bulleted-list', // 第二层
            children: [
                { type: 'list-item', children: [{ text: 'x' }] },
                { type: 'list-item', children: [{ text: 'y' }] },
                {
                    type: 'bulleted-list', // 第三层
                    children: [
                        // ... 继续第 N 层
                    ]
                }
            ]
        }
    ]
}

编辑器需要编辑内容,编辑和渲染不一样。
如果仅仅是渲染内容,那层级多了也没关系,一层一层渲染即可,而且层级多了还有利于渲染,所以 HTML 设计的多级列表就是这样多层嵌套的。
但编辑内容就不一样了,需要控制选区,需要判断选中的节点,需要对选区内的节点进行编辑、修改属性等。一旦数据层级多了,将对编辑、选区等操作造成极大的困难,也大大增加 bug 发生概率。

PS:wangEditor4 发生的大部分编辑、格式操作的 bug,都是因为 HTML 结构不可控导致的。所以花了 1 年时间来重构 wangEditor5 。

简化数据结构

基于以上分析,要想稳定的实现多级列表,就要简化数据结构,防止嵌套层级。

参考文本的数据结构

其实 wangEditor5 对于文本格式的处理,也是这个思路:不用嵌套格式,文本节点全部都是单层结构。

<p>a<b>b<i>c<u>d</u></i></b></p>

例如,上面这个嵌套结构的 HTML ,到编辑器内部就转换成了单层的 JSON 格式

{
    type: 'paragraph',
    children: [
        { text: 'a' },
        { text: 'b', bold: true },
        { text: 'c', bold: true, italic: true },
        { text: 'd', bold: true, italic: true, underline: true },
    ]
}

单层的文本节点,非常便于选区的管理和操作,也便于内容的编辑、样式的修改。现代流行的开源编辑器,都是这样设计的。

重构列表数据结构

要简化列表的数据结构,需要去掉 list 层,只保留 item 层。如下:

[
    { type: 'list-item', children: [{ text: 'a' }] },
    { type: 'list-item', children: [{ text: 'b' }] }
]

要表示层级,就增加一个 level 属性,如下:

[
    { type: 'list-item', level: 0, children: [{ text: 'a' }] },
    { type: 'list-item', level: 1, children: [{ text: 'b' }] },
    { type: 'list-item', level: 2, children: [{ text: 'c' }] },
    { type: 'list-item', level: 3, children: [{ text: 'd' }] },
]

image.png

要表示有序列表,就增加一个 ordered 属性,如下:

[
    { type: 'list-item', level: 0, ordered: true,  children: [{ text: 'a' }] },
    { type: 'list-item', level: 0, ordered: true, children: [{ text: 'b' }] },
    { type: 'list-item', level: 1, ordered: true, children: [{ text: 'c' }] },
    { type: 'list-item', level: 1, ordered: true, children: [{ text: 'd' }] },
]

image.png

JSON 渲染到编辑器

此前单级列表是直接用 <ul> <ol> 渲染的。现在改用单层数据结构,也需要使用同样的单层 DOM 结构。

使用 <div> 模拟列表效果即可,单层 JSON 结构转换为单层 DOM 结构,非常简单。
需要渲染层级,增加 margin-left 即可实现,不同层级的前缀符号不一样,分别是

image.png

无序列表这些就够了,但有序列表需要计算序号,而且需要根据不同层级计算序号,稍微麻烦一点。

image.png

大概的思路是:针对一个 item ,向上寻找,能找到同类型的 item ,就累加序号。核心代码如下:

  let num = 1
  let curElem = elem
  let curPath = DomEditor.findPath(editor, curElem)

  while (curPath[0] > 0) {
    const prevPath = Path.previous(curPath)
    const prevEntry = Editor.node(editor, prevPath)
    if (prevEntry == null) break
    const prevElem = prevEntry[0] as ListItemElement // 上一个节点
    const { level: prevLevel = 0, type: prevType, ordered: prevOrdered } = prevElem

    // type 不一致,退出循环,不再累加 num
    if (prevType !== type) break
    // prevLevel 更小,退出循环,不再累加 num
    if (prevLevel < level) break

    if (prevLevel === level) {
      // level 一样,如果 ordered 不一样,则退出循环,不再累加 num
      if (prevOrdered !== ordered) break
      // level 一样,order 一样,则累加 num
      else num++
    }

    // prevLevel 更大,不累加 num ,继续向前
    curElem = prevElem
    curPath = prevPath
  }

JSON 输出 HTML

执行 editor.getHtml() 可获得当前编辑器的 HTML 内容,即编辑器所有的 elems 的 HTML 拼接起来的。所以,就需要规定列表的数据,如何转换为 HTML 。

编辑器内部虽然用 <div> 单层结构模拟 <ul> ,但真正输出 HTML 是还是需要使用正规的 HTML 格式的,即 <ul><li>a</li><li>b</li></ul> 这嵌套格式。

注意:编辑器内部 DOM 结构 !== editor.getHtml() —— 这一点很重要!

image.png

难度就在这里:单层结构 -> 嵌套结构。解决方案就是:针对每一个 item ,判断它是不是“第一个”或者“最后一个”。

  • “第一个”前面就需要拼接 <ul><ol>
  • “最后一个”后面需要拼接 </ul></ol>

还有,前后拼接 tag 时,不一定就是一个。当多层嵌套时,前后可能需要拼接多个 <ul> </ul> 等。、 所以,这里就需要用到 :拼接 tag 开头时压栈,拼接 tag 结尾时出栈。核心代码如下

// ol ul 栈
const CONTAINER_TAG_STACK: Array<string> = []

function elemToHtml( elem: Element, childrenHtml: string) {
  let startContainerStr = ''
  let endContainerStr = ''

  const { ordered = false } = elem as ListItemElement
  const containerTag = ordered ? 'ol' : 'ul'

  // 前面需要拼接几个 <ol> 或 <ul>
  const startContainerTagNumber = getStartContainerTagNumber(elem)
  if (startContainerTagNumber > 0) {
    for (let i = 0; i < startContainerTagNumber; i++) {
      startContainerStr += `<${containerTag}>` // 记录 start container tag ,如 `<ul>`
      CONTAINER_TAG_STACK.push(containerTag) // tag 压栈
    }
  }

  // 后面需要拼接几个 </ol> 或 </ul>
  const endContainerTagNumber = getEndContainerTagNumber(elem)
  if (endContainerTagNumber > 0) {
    for (let i = 0; i < endContainerTagNumber; i++) {
      const tag = CONTAINER_TAG_STACK.pop() // tag 从栈中获取
      endContainerStr += `</${tag}>` // 记录 end container tag ,如 `</ul>`
    }
  }

  return {
    html: `<li>${childrenHtml}</li>`,
    prefix: startContainerStr, // 前缀 <ol> 或 <ul>
    suffix: endContainerStr, // 后缀 </ol> 或 </ul>
  }
}

解析 HTML 生成 JSON 数据

执行 editor.setHtml('<ul><li>a</li><li>b</li></ul>') 会把 HTML 插入到编辑器内部。即把 HTML 嵌套结构转换为编辑器内部单层的 JSON 结构。

image.png

难点就在 嵌套结构 -> 单层结构 ,解决的核心 API 很简单:数组的 flat 扁平化。

// 解析 <ul> 和 <ol>
function parseListHtml( elem: DOMElement, children: Descendant[], editor: IDomEditor) {
  // children 即嵌套的 <li> <ul> <ol>
  return children.flat(Infinity) // 彻底扁平化
}

还要计算每个 item 的 orderedlevel

  • 判断 item 父节点,如果是 <ol> 则标记 ordered
  • 从 item 向上遍历,看看有多少层 <ul><ol>,即可得出 level
// 解析 <li>
function parseItemHtml(elem: DOMElement, children: Descendant[], editor: IDomEdito): ListItemElement {
  const $elem = $(elem)

  // 过滤 children ,此处省略 N 行

  const ordered = getOrdered($elem)
  const level = getLevel($elem)

  return {
    type: 'list-item',
    ordered,
    level,
    children,
  }
}

一张图总结

本文的内容总结一下,就是下图的过程

  • renderElem - 将 JSON 结构渲染为编辑器 DOM(全部是单层内容)
  • toHtml - 将 JSON 结构转换为 HTML 内容(单层结构 -> 嵌套结构)
  • parseHtml - 将 HTML 内容转换为 JSON 结构(嵌套结构 -> 单层结构)

image.png

结束

对于一个合理需求,如果算法很难实现,那很可能是数据结构设计错了。
优化数据结构,比优化算法更重要。

PS:未来 table 也可以通过此种方式来简化。