如何为富文本添加目录与评论
上一篇文章谈到了协同编辑的实现,协同编辑有一个绕不开的话题就是富文本编辑器,近几年来,富文本编辑器的场景也是在不断的拓宽,相关的产品也是层出不穷。在产品体验方面,大家的体验也是趋近于一致,但是在实现方面,有不小的区别的。像 wps 云文档采用的是 svg 实现,还有使用 canvas 实现的,这种方式脱离浏览器自带编辑能力,独立做光标和排版引擎,效率高,灵活,相应的门槛也高,可以说是未来的趋势。石墨则是在 Quill 的基础上二次开发,依赖于浏览器提供的 contentEditable 等基础功能,效果也很好。
不管怎样,富文本编辑器对于自定义功能的要求是越来越高了,Quill 对于自定义开发的支持还不错,本文就使用 Quill 来实现目前文本类产品中必不可少的两个功能, 自动生成目录与添加评论 。
Quill 与 Parchment
做自定义模块之前首先简单的了解下 Quill,谈起 Quill, 最有名的就是数据模型 Delta 了,Delta 被用做描述 Quill 编辑器的内容和变化。它非常适合 Operational Transform,我们在将协同编辑的时候已经讲过 OT 算法的用处,就不赘述了。
Quill 的文档模型是由 Parchment 来定义的。 什么是 Parchment?为什么要有 Parchment ?回答这个问题,我们可以反过来想一想,如果我们使用原生的 DOM 节点作为文档模型,会有什么问题?
- 相同的 UI 存在不同的描述方式,比如文本加粗的操作,在 chrome 上,是添加 bold 标签,IE11 上则是添加了 strong 标签。兼容性问题是浏览器的大坑。
- 复杂的交互意图,比如我们按下 Enter 键,那当前行的样式是否要带入到下一行呢,这是要根据实际情况判断的,像颜色,加粗这类的应该带到下一行,但是像标题就不应该影响下一行。DOM 做这种判断太过于繁琐和复杂。
- 内容输入的多样性,内容不仅可以输入,也可以来自于复制黏贴,拖拽等方式。
- 自定义模块的需求,比如我们需要插入一个图表,需要在表格中进行复杂的操作,需要插入公式,在做这种模块的时候,DOM 的表现力是差很多的,而且随着结构的复杂,会出现很多莫名其妙的问题。
这就需要一个中间层在 DOM 之上做一层封装,这个中间层可以 规范 DOM 的操作,封装公共行为,统一处理标准,抽象生命周期,提供合适的 API 。目前开源的富文本编辑器基本上都是这个套路, Quill 使用的是 Parchment。Draft 使用 React 。
Parchment 可以简单的理解为是 DOM 的抽象,如果你使用 React 的话,对这种抽象应该很熟悉。当然,Parchment 相较于 React 的虚拟 DOM 来说简单很多。 Parchment 由 Blot 和 Attributor 组成 ,Blot 有很多种,纯文本的 Blot 是 TextBlot, 块级的是 BlockBlot,内联的是 InlineBlot,EmbedBlot 表示复杂结构,并且每种 Blot有 自己的生命周期。Attributor 提供格式化的信息,一般用来表示元素的类,样式。
这样抽象的好处在于,我们可以根据 Blot 的类型处理了很多复杂的逻辑,比如块级元素和内联元素在 Enter 下的不同行为。Blot 定义的生命周期使我们可以规范化的创建 DOM 节点,修改 DOM 节点的属性。Blot 对外提供了很多封装好的方法,方便我们做扩展。本文中实现的自动生成目录与添加评论就是通过 Blot 对外提供的方法来实现的。
Quill 的本质就是基于 Parchment 这套文档模型,使用 Delta 数据模型,加上一些富文本必须的模块(比如历史记录,键盘,粘贴板,工具栏)组成的。当然,他也内置了一些文字大小,颜色,有序列表,图片等模块,方便大家开箱即用。但是不得不说,Quill 这些内置模块都太简单了,真正用起来体验一般,想要一个好用的富文本编辑器,在 Quill 的基础上二次开发才是王道。
如何自动生成目录
Quilljs 官网 提供了一个构建一个自定义模块的 Demo,我们可以使用类似的方式,为富文本生成一个目录。最终实现的效果如下所示:富文本左侧为目录区域,根据富文本呢中的标题自动生成不同等级的目录,点击目录后,会跳转到对应的文本区域。
Quill 中的模块就是一个 Class,当我们把这个 Class 注册到 Quill 中的时候,Quill 会负责实例化这个类,并且将 quill 实例通过构造函数传给这个类。总的来说, Quill 模块就是一个拿到了 quill 实例并且能操作这个实例的类 。Quill 提供了注册方式,基本所有的扩展我们都需要通过这种方式进行注册。
import Menu from '../../modules/menu';
import Quill from 'quill';
Quill.register(
'modules/menu': Menu,
使用起来也很简单,定义 Editor 的时候,将 menu 加入 modules 中即可。
const options: QuillOptionsStatic = {
modules: {
menu: {
container: '#editor-menu'
theme: 'snow'
如何生成一个 Menu 呢?思路是这样的,Quill 中所有的元素都可以认为是由 Blot 组成的,H1 H2 H3 H4 是 HeaderBlot,我们可以找出文档中所有的 HeaderBlot,然后获得这些 Blot 的真实的 DOM 节点,这样就能获得这些标题的内容和位置,通过内容和位置生成一个目录。其中的很多操作都依赖于 Blot 和 Quill 封装的 API。这里列出以下用到的 API。
descendants<T>(type: { new (): T }, index: number, length: number): T[];
descendants 用于获得指定范围内特定的 Blot,第一个参数查找的 Blot 的类,后两个参数用于指定范围。这个函数在 Quill 模块中大量使用。
getText(index: Number = 0, length: Number = remaining): String
返回编辑器的字符串内容。由于非字符串内容会被忽略掉,所以返回字符串内容的长度会比 getLength 返回的长度小。注意,尽管 Quill 为空,编辑器里仍有一行空行,这种情况会返回”\n”。
以下列出 menu 模块的核心代码。通过监听文本的改变事件,获得当前文档内 HeaderBlot 的具体 DOM 类型以及内容,如果两次获得的不一致,说明 Header 的内容或是格式发生了变化,此时触发 Menu 的渲染逻辑。
class Menu extends Module {
constructor(quill, options) {
this.quill.on(Quill.events.TEXT_CHANGE, () => {
update();
update() {
const newMenus = [];
this.headerBlots = [];
this.quill.scroll.descendants(Header).forEach((header, index) => {
const type = Header.formats(header.domNode);
const value = this.quill.getText(header.offset(), header.length());
this.headerBlots.push(header);
newMenus.push({
type,
value,
index
if (!_.isEqual(newMenus, this.menus)) {
this.menus = newMenus;
this.render();
render() {
renderMenu(this.menus, this.containerNode);
Quill 是一个跨平台的富文本编辑器,它本身不包含任何框架,使用 document api 进行 DOM 的绘制。我为了快速实现功能,使用 React 和 Antd 对其进行二次开发,因此,Menu 的渲染我使用了 Antd。Quill 可以很方便的接入任何的 UI 框架。
以下是简化版本的 Menu 的 JSX 写法,需要注意的是,我们需要使用 ReactDom.render 将这个 JSX 嵌入到规定的 DOM 下。因此对外暴露 renderMenu 方法供 menu 模块调用。
import React from 'react';
import ReactDom from 'react-dom';
import { List } from 'antd';
const Menu: React.FC<IProps> = (props) => {
const { list } = props;
return (
header={<div>目录</div>}
dataSource={list}
renderItem={(menu) => (
<List.Item>
{menu.value}
</List.Item>
export const renderMenu = (list: menuItem[], container: HTMLElement) => {
ReactDom.render(
<Menu list={list} />,
container
自动滚动是实现也很简单,点击目录中的某行,由于目录和富文本中的 HeaderBlot 是一一对应的,我们可以通过该行在目录中的位置,找到该 HeaderBlot,使用 scrollTo 方法滚动到该元素位置。
onClick(menu) {
const { index } = menu;
const { domNode } = this.headerBlots[index];
const scrollContainer = this.quill.scrollingContainer;
if (domNode && domNode.offsetTop) {
scrollContainer.scrollTo({
top: domNode.offsetTop,
behavior: 'smooth'
如何为富文本添加评论
评论功能是富文本很常见的一个功能,该模块比较复杂,我们需要实现以下几点。
- 根据圈选的文字或是光标所在的行添加评论。
- 评论内容不能互相遮挡,要尽可能的靠近文本所在位置。
- 可以根据文本快速找到对应的评论。
最终的效果如下所示。
首先需要定义一个 Blot,用于表示评论。这个 Blot 一定是行内的,因为我们可以为某行的部分内容增加评论,在表现上,他就是一个加了特殊类的 sapn 标签,该标签上增加一个 data-comment-guid 属性,该属性是全局唯一的 id,用于和侧边的评论信息做关联。
我们先简单的注册一个 CommentBlot。
class CommentBlot extends Inline {
static create(value) {
const node = super.create(value);
node.setAttribute('data-comment-guid', value);
return node;
static formats(domNode) {
return domNode.getAttribute('data-comment-guid');
format(name, value) {
if (name !== this.statics.blotName || !value) {
super.format(name, value);
this.domNode.setAttribute('data-comment-guid', value);
CommentBlot.blotName = 'comment';
CommentBlot.tagName = 'span';
CommentBlot.className = 'ql-commented';
export default CommentBlot;
和一般模块通过 toolbar 上的按钮触发方式不同,评论的添加按钮一般位于当前光标所在的行的右侧,会随着光标移动,和 Menu 的处理方式类似,我们监听富文本编辑器的 EDITOR_CHANGE 事件,拿到当前光标所在的位置,然后将光标的位置转换为元素的相对位置,更新评论的添加按钮的位置。用到的方法是 getBounds,该方法返回给定选区相对于编辑器容器的的像素位置和尺寸。
getBounds(index: Number, length: Number = 0): { left: Number, top: Number, height: Number, width: Number }
这里还有两个小细节,一是如果某段圈选的文本已经添加了评论,就不能再添加了,我们需要判断当前光标位置是否存在评论。用到的方法是 getFormat,该方法传入选区的 range,返回该选区全部的 Blot。我们可以查找这些 Blot 中是否存在 CommentBlot 来判断是否已经被评论。
getFormat(range?: RangeStatic): StringMap;
还有一个细节就是当某行为空的时候,也不应该能够添加评论,因此我们需要判断当前行是否是个空行。用到的方式是 getLine,该方法通过传入光标当前位置获得当前行的 Blot 等数据。如果是个空行的的话,返回 Blot 的 length 也不是 0,此时会存在一个 br 标签,length 是 1。
getLine(index: number): [any, number];
大致的代码如下:
updateCommentIcon() {
const range = this.quill.getSelection();
if (range) {
// 已经评论过的不显示按钮
const formats = this.quill.getFormat(range);
if (Object.keys(formats).indexOf('comment') === -1) {
const [line, offset] = this.quill.getLine(range.index);
// 本行无内容不显示
if (line.length() > 0) {
const bounds = this.quill.getBounds(range.index, range.length);
this.updateCommentIconPosition(bounds);
} else {
this.commentIcon.style.display = 'none';
} else {
this.commentIcon.style.display = 'none';
点击新增按钮后,会在当前的按钮右侧出现一个表单(由于评论卡片的存在,此时的布局也是一个比较麻烦的问题,我们后续说这个问题)。现在假设我们在表单中填入了评论信息,点击提交,如何让这个评论在富文本中生效呢?
首先需要调用 focus 方法让富文本重新聚焦。focus 方法会恢复编辑器最后一次的选择。如果我们在添加评论的时候没有选中文字,则认为是选中当前行。如果选中了文字,那么对这段文本执行 format 方法。
format 用于设置用户当前选择文本的格式,返回一个代表变化的 Delta 数据。
format(name: String, value: any, source: String = 'api'): Delta
代码如下所示:
formatComment(guid) {
this.quill.focus();
const range = this.quill.getSelection();
if (range) {
const { index, length } = range;
// 如果没有选中文字,认为是选中当前行
if (length === 0) {
const [line, offset] = this.quill.getLine(index);
this.quill.setSelection(index - offset, line.length());
this.formatComment(guid);
} else {
this.quill.format('comment', guid, Quill.sources.USER);
return range;
单独说下评论的布局问题,由于评论的内容不定,高度不定,如果做才能让评论之间不互相覆盖呢?WPS 中是这么做的。默认情况下,评论会根据文本的位置放置在右侧区域,但是在空间不足的情况下,比如一行有多个评论,按照文本中位置先后依次往下排。当用户光标所在的文本存在评论时,不管顺序是怎样的,该评论严格位于文本右侧,上下的评论需要移动出足够的位置。
因此我们为评论增加一个属性 selected,用于标识当前聚焦的评论,该评论严格位于文本右侧。该评论的位置确定后,依次向前和向后计算评论的位置。
interface IComment {
guid: string;
comment: string;
date: number;
top: number;
selected: boolean;
以后向计算为例,我们已知当前评论的位置,以及下一个评论的位置,如果这两个位置之间的空足够大,那就按照原先的位置排布,不需要移动。如果不够大,就需要做下位移。后一个的位置永远依赖于前一个计算出的位置一个前一个评论区域的高度。
for (let i = after; i < cardList.length - 1; i += 1) {
const height = cardList[i].offsetHeight;
if (calculatedTransform[i] + commentList[i].top + height > commentList[i + 1].top) {
calculatedTransform[i + 1] = calculatedTransform[i] + commentList[i].top + height - commentList[i + 1].top;
} else {
calculatedTransform[i + 1] = 0;
前向计算是反过来的,选中的评论位置固定在文本的右侧,那么上方的评论必然会受到影响,间距不够的,需要向上做位移。为选中的评论添加特殊样式。
一般为了能更好的将文本和评论做关联,点击一个评论后,需要默认选中文本内容。所以为评论添加点击事件。根据评论的 guid 对富文本中的 CommentFormat 做筛选。由于存在多行文本对应一个评论的情况,我们需要对单行和多行做不同的处理。核心就是使用 setSelection 方法,该方法给定范围设置用户的选区,同时焦点在编辑器上。
onClick(guid) {
const commentBlotList = this.quill.scroll.descendants(CommentFormat).filter((item) => CommentFormat.formats(item.domNode) === guid);
// 单行选中
if (commentBlotList.length === 1) {
const index = this.quill.getIndex(commentBlotList[0]);
const length = commentBlotList[0].length();
this.quill.setSelection(index, length);
} else if (commentBlotList.length > 1) {
// 多行选中
const index = this.quill.getIndex(commentBlotList[0]);
const lastItem = commentBlotList[commentBlotList.length - 1];
const length = this.quill.getIndex(lastItem) + lastItem.length();
this.quill.setSelection(index, length);
CommentBlot 继承自 Inline,是个行内元素,但是它的部分行为和 Block 元素类似。比如 Enter 时的行为。默认情况下,行内元素的 Enter 会将当前行的样式带到下一行。但是评论不是这样的。你为某行添加了一个评论,此时进行换行,你一定不希望下一行也包裹在这个评论内。
Quill 中的 keyboard 作为核心模块,可以处理这种情况。collapsed 为 true 表示用户的选择区是收缩状态(在光标形式下)。format 表示必须是处于 comment 状态是才会触发。suffix 表示此时 comment 之后没有任何内容。
我们为 comment 绑定一个 enter 事件。该事件会优先于默认的 enter 事件,我们在这个事件中拼接 Delta 的逻辑。将换行后 comment 的值置为 null 即可。
const options: QuillOptionsStatic = {
modules: {
keyboard: {
bindings: {
'comment enter': {
key: 'Enter',
collapsed: true,
format: ['comment'],
suffix: /^$/,
handler(range:any, context:any) {
commentEnter(range, context, this.quill);
const commentEnter = (range, context, quill) => {
const [line, offset] = quill.getLine(range.index);
const delta = new Delta()
.retain(range.index)
.insert('\n', context.format)
.retain(line.length() - offset - 1)