哔哩哔哩技术

从零开始的富文本编辑器(下)

本期作者

胡炜轩

资深开发工程师

往期回顾: 上次我们介绍了 《从零开始的富文本编辑器(上)》 ,其中我们探讨了contenteditable属性以及slatejs框架的api,本篇我们将接续上篇内容着重介绍一些实践细节。


03 使用slatejs开发一个富文本编辑器

首先我们进入slatejs的网站


https://docs.slatejs.org/(点击文末左下角“ 阅读原文 ”直达网站)

点击examples,可以看到一个官方示例:


点击View Source进入github


好了本章结束😊

开个玩笑。

先简单分析下demo中的代码:


工具栏区

可以看到加粗、斜体、下划线等标记被定义为Mark,对应前文中提到的Text元素,其余如大小标题、引用、列表、对齐等,则被定义为Block,对应前文中Element的Block属性。


Mark的处理

点击工具栏的加粗、斜体、下划线等按钮,会调用 toggleMark 方法,为元素添加标记:


点击之后会编辑器中会生成对应的结构:


其对应的渲染如下:


Block的处理

点击工具栏的大小标题、引用、列表、对齐等按钮,会调用 toggleBlock 方法:


红框内为核心逻辑,即先将结点变成普通的文本,然后将其改变到目标类型。demo中的代码对list(即DOM中的ul、ol)做了特殊处理,是因为作者想让list元素可以包裹住其他元素,而不是直接改变。

比如用户框选了一个标题元素,再点击列表按钮,则会把这个将这个标题放在list下的一个item中。此处大家可以根据实际需求修改。比如我们这里的需求是list可以有多层,我会在后文中单开一节来详细描述其实现方案。


点击之后会编辑器中会生成对应的结构:


其对应的渲染如下:


ok,现在你已经懂得了如何通过slatejs实现简单的富文本编辑器了。但是整个demo中,只有文字排版相关的内容,并没有图片。

我会在后文单开一节来介绍图片的实现。


04 slatejs富文本编辑器之列表

需求描述

在我们的场景里面,需要实现如下效果:

列表支持多级,无序列表的前标为实心圆、空心圆、实现方块,超过3级则按这个顺序重复。

有序列表的前标则为阿拉伯数字、英文字母、罗马数字。



如果你对DOM了解,可以发现ul的嵌套,默认就是这个样子的,ol则需要定义一下css。


实现方案

点击按钮的实现与前文中所述相同。要创建一个多级列表,用户常见的操作是回车后按tab键,在word之类的软件中都是如此。那这里要怎么做呢?


首先是换行的处理,当然不是捕获键盘事件判断是否是回车再执行命令。这里我们覆写Editor中默认的insertBreak方法。

insertBreak方法默认会向编辑器选中区增加换行。

// withLists handles behavior regarding ol and ul lists// more specifically, withLists properly exits the list with `enter` or `backspace`// from an empty list item, transforming the node to a paragraphexport const withLists = (editor: Editor) => {  const { insertBreak, deleteBackward } = editor  const doWithLists = (callback: Function) => {    const { selection } = editor    // check that there is a current selection without highlight    if (selection && Range.isCollapsed(selection)) {      // find the 'closest' `list-item` element      const [match] = Editor.nodes(editor, {        match: (n: any) =>          n.type === 'list-item' &&          n.children &&          n.children[0] &&          (!n.children[0].text || n.children[0].text === ''),      })      // check that there was a match      if (match) {        const [, path] = match        const start = Editor.start(editor, path)        // if the selection is at the beginning of the list item        if (Point.equals(selection.anchor, start)) {          // 'lift' the list-item to the next parent          liftNodes(editor)          // check for the new parent          const [listMatch] = Editor.nodes(editor, {            match: (n: any) =>              n.type === 'bulleted-list' || n.type === 'numbered-list',          })          // if it is no longer within a ul/ol, turn the element into a normal paragraph          if (!listMatch) {            Transforms.setNodes(              editor,              { type: 'paragraph' },              { match: (n: any) => n.type === 'list-item' }            )          }          return        }      }    }    callback()  }  // override editor function for break  editor.insertBreak = () => {    doWithLists(insertBreak)  }  // override editor function for a backspace  editor.deleteBackward = (unit) => {    doWithLists(() => deleteBackward(unit))  }  return editor}

核心代码逻辑是先在选中区中寻找list元素,如果找到则可判断当前处于一个list当中。

此时回车执行liftNodes方法。liftNodes的核心逻辑是Trasnforms.liftNodes,它的功能为在文档树中向上提升指定位置的节点。如有必要,将拆分节点。

比如我在一个list-item的中部敲回车,那么回车前的文字会留在原处,而回车后的文字会从Text变为一个Element,由于它处于一个list中,会自动变成一个list-item。


接下来我们处理缩进,如果当前处于一个list当中,则将选中处再用list元素包装一层,形成类似DOM中ul...li...ul的结构,完成缩进。如果当前不处于list中,直接加空格或制表符完成缩进。

export const indentItem = (editor: Editor, maxDepth = MAX_DEPTH) => {  const { selection } = editor  // check that there is a current selection without highlight  if (selection && Range.isCollapsed(selection)) {    const [match] = Editor.nodes(editor, {      match: (n: any) => n.type === 'list-item',    })    // check that there was a match    if (match) {      // wrap the list item into another list to indent it within the DOM      const [listMatch] = Editor.nodes<any>(editor, {        mode: 'lowest',        match: (n: any) => n.type === 'bulleted-list' || n.type === 'numbered-list',      })      if (listMatch) {        let depth = listMatch[1].length        if (depth <= maxDepth) {          Transforms.wrapNodes(editor, {            type: listMatch[0].type,            children: [],          })        }      }    } else {      Transforms.insertText(editor, '    ', {        at: { path: [selection.anchor.path[0], 0], offset: 0 },      })    }  }}

取消缩进是其反操作,如果当前是list,则将其置为paragraph(段落,最普通的纯文本Element)。如果当前不是list,判断前面是否有可以删除的空格。

export const undentItem = (editor: Editor) => {  const { selection } = editor  // check that there is a current selection without highlight  if (selection && Range.isCollapsed(selection)) {    const [match] = Editor.nodes(editor, {      match: (n: any) => n.type === 'list-item',    })    // check that there was a match    if (match) {      // 'lift' the list-item to the next parent      liftNodes(editor)      // check for the new parent      const [listMatch] = Editor.nodes(editor, {        match: (n: any) => n.type === 'bulleted-list' || n.type === 'numbered-list',      })      // if it is no longer within a ul/ol, turn the element into a normal paragraph      if (!listMatch) {        Transforms.setNodes(          editor,          { type: 'paragraph' },          { match: (n: any) => n.type === 'list-item' }        )      }    } else {      const [pMatch] = Editor.nodes(editor, {        match: (n: any) => n.type === 'paragraph'          || n.type === 'block-quote'          || n.type === 'heading-one'          || n.type === 'heading-two'          || n.type === 'heading',      })      if ((pMatch[0] as any).children[0].text.startsWith('    ')) {        Transforms.delete(editor, {          at: { path: [selection.anchor.path[0], 0], offset: 0 },          distance: 4        })      }    }  }}

以上就是多级列表的实现。目前整体还比较简单,后文我们会开始讲解目前整个编辑器中最复杂的图片。


05 slatejs富文本编辑器之图片

终于来到了最复杂的图片元素。

我们还是先打开官方demo,在左侧的菜单中选择images:


然后你会看到这样的demo:


Exciting!是不是又结束了?然鹅并没有。


我们点击图片按钮,会发现要求我们输入一个图片的链接。这显然过于简陋。我们需要让用户直接选择本地的图片上传。


方案设计

经过与服务端的讨论我们确定了三个方案:


方案一 前端选择图片文件后,将文件转换为base64url,插入编辑器中,服务端在提交时解析所有的image结点上传,并修改链接。 此方案的 优点 是用户编辑是体验最好,缺点是服务端处理数据压力较大,特别是图片多的时候。pass。

方案二 前端选择图片后,先向编辑器中插入一个loading元素占位,等服务端返回后再将其替换为真实地址。 优点 是实现简单,服务端压力小,缺点是loading元素容易作为中间状态被保存下来,图片较大时loading等待时间长,整体交互用户体验交较差。

方案三 前端提供一个浮层批量上传图片,轮询服务端查询上传状态,同时服务端做异步化改造。所有图片上传成功后,一次性插入编辑器。此方案实现较复杂,但用户体验较好。

综合来看,我们选择 方案三 进行开发。
实现方案

将图片定义为空元素:

const withImages = editor => {  const { isVoid } = editor  editor.isVoid = element => {    return element.type === 'image' ? true : isVoid(element)  }  return editor}

用户点击工具栏图片按钮时,弹出浮层让用户进行上传。上传完毕后,对所有图片链接,调用insertImage方法:

export const insertImage = (editor: Editor, url: string) => {  const { selection } = editor  const text = { text: '' }  const image: ImageElement = { type: 'image', url: trimHttp(url), desc: '', children: [text] }  if (selection) {    const [match] = Editor.nodes(editor, {      match: (n: any) =>      n.type === 'paragraph' &&      n.children &&      n.children[0] &&      (!n.children[0].text || n.children[0].text === ''),    })    if (match) {      Transforms.setNodes(editor, image, {mode: 'highest'})    } else {      Transforms.insertNodes(editor, image, {mode: 'highest'})    }  } else {    Transforms.insertNodes(editor, image, {mode: 'highest'})  }}

如果当前光标在一个空行上,则将当前元素之间转换为图片元素。

否则,在光标位置插入一个图片元素。若编辑器中不存在焦点,插入编辑器末尾。


对应渲染方法:

const Image = ({ attributes, children, element }) => {  const editor = useSlateStatic()  const path = ReactEditor.findPath(editor, element)  const selected = useSelected()  const focused = useFocused()  return (    <div {...attributes}>      {children}      <div        contentEditable={false}        className={css`          position: relative;        `}      >        <img          src={element.url}          className={css`            display: block;            max-width: 100%;            max-height: 20em;            box-shadow: ${selected && focused ? '0 0 0 3px #B4D5FF' : 'none'};          `}        />      </div>    </div>  )}

注意这里将图片元素的内部设置为了contentEditable=false。如果不设置这个属性,图片的前后会出现光标,可以输入文字。如果想要优化这个效果,需要监听onkeydown等事件,在图片前后输入内容进行自动换行等。

图片结构上也可以自由扩展,比如增加一个输入框输入图片注释等,这里就不展开讲了。


至此,我们完成了【从零开始的富文本编辑器】中的所有内容。当然,实际开发的过程中,为了满足各种编辑需求,还需要添加快捷键等功能,同时多种排版之间状态的切换也需要仔细打磨,更不要提莫名其妙的兼容性bug了。

这些问题都不是一个所谓“完美“的架构能够解决的,更多需要你去思考怎样的体验是更优秀的,去花时间研究不同浏览器的区别并解决问题。这大概就是一个前端工程师的宿命了。


以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,请给我们点个赞吧!

往期参考: 《从零开始的富文本编辑器(上)》

本期参考链接:

[1]https://www.slatejs.org/examples/richtext

[2]https://github.com/ianstormtaylor/slate/blob/main/site/examples/images.tsx