使用quill搭建文本编辑器

官网:https://quilljs.com

quill可以是一个文本编辑器JS库或是文本编辑器构建库。它提供了结构化数据方式用脱离语言的方式描述编辑器内容,同时预置了内置插件,支持自定义插件,有助于在此基础上进行和业务相关编辑器开发。

准备工作

关于contenteditable属性,selection对象和range对象的介绍,可以参考这篇文章

配置

1
2
3
const editor = new Quill("#container");
// 或是直接传入DOM对象
const editor = new Quill(document.body);

quill通常使用上面的方式初始化。在初始化时,支持用丰富的配置项定义生成的编辑器。下面是一个例子。

1
2
3
4
5
6
7
8
9
10
const options = {
debug: 'info',
modules: {
toolbar: '#toolbar'
},
placeholder: 'Tell a story...',
readOnly: false,
them: 'snow'
}
const editor = new Quill('#container', options);

Quill支持的配置项有:

  • bounds,Quill UI元素的限制范围,默认document.body
  • debug,是Quill.debug的快捷方式,用于打印调试信息,默认级别为warn
  • formats,Quill中允许出现的格式,默认为所有格式,它和toolbar是解耦的
  • modules,注册在Quill中的功能模块和与之对应的配置信息,Quill会有默认配置
  • placeholder,提示信息
  • readOnly,是否可写
  • scrollContainer,编辑器滚动条的父级,默认为编辑器本身
  • strict,版本更新配置,默认为true
  • theme,整体外观,默认为snowbubble可选

支持的文本格式

分为行内块级嵌入式三大类。

  • 行内:加粗/背景色/字体颜色/字体/行内代码/斜体/下划线/删除线/链接/字体大小/上、下标
  • 块级:引用/标题/行首缩进/有序、无序列表/对齐/文本方向/代码块
  • 嵌入式:音频/视频/公式

API

按照由浅入深,分为修改内容修改格式选取编辑器本身事件数据模型操作拓展几大类。涉及到内容修改的都会返回代表更改的delta。

修改内容

涉及到修改时,最后一个参数都可以选择userapisilentuser类型下,disabled时会没有效果。

  • deleteText,输入起始点和长度,删除特定范围的内容,返回delta类型数据。如quill.deleteText(6, 4)
  • getContents,获取delta格式的编辑器内容,如quill.getContents()
  • getLength,获取文本长度,quill默认会有一个空行,所以默认返回1
  • getText,获取文本内容,跳过非文本如音视频元素,如quill.getText(0, 10)
  • insertEmbed,输入位置,类型,值,插入嵌入式内容,如quill.insertEmbed(10, 'image', 'https://quilljs.com/images/cloud.png')
  • insertText,插入文本,可带格式。如下
    1
    2
    3
    4
    5
    6
    quill.insertText(0, 'Hello', 'bold', true);

    quill.insertText(5, 'Quill', {
    'color': '#ffff00',
    'italic': true
    });
  • setContent,输入delta,重设编辑器内容,需以\n结尾。如下:
    1
    2
    3
    4
    5
    quill.setContents([
    { insert: 'Hello ' },
    { insert: 'World!', attributes: { bold: true } },
    { insert: '\n' }
    ]);
  • setText,设置文本,返回代表改变的delta
  • updateContent,输入delta,更新内容,返回代表更新的delta

修改格式

  • format,设置用户当前所选的文本格式,如quill.format('color', 'red');
  • formatLine,设置给定选择当前整行样式,使用类似format的方法设置样式,也支持直接传入格式对象。类似quill.formatLine(1, 2, { 'align': 'right'})
  • formatText,设置给定范围内文本格式,类似formatLine
  • getFormat,获取给定范围内的格式,没有输入时,返回当前选择的格式
  • removeFormat,移除范围内样式

选取

  • getBounds(index, length = 0),返回的top、width、height、left相对于编辑器容器而言
  • getSelection(focus = false),返回用户的选取范围,由index、length组成
  • setSelection(index, length = 0),设置选区范围,会自动focus,输入null会自动blur

编辑器本身

  • blur,失焦
  • disable,禁用
  • enable(enabled = false),启用
  • focus,聚焦
  • hasFocus,是否聚焦
  • update,同步用户改动,协同工作时常用

事件

事件通过on方法绑定在quill对象上。

  • text-change,quill内的内容改变时触发,回调函数可以获取delta、oldContent,source。通常来自’user’,source为’silent’时,该事件不会触发。
  • selection-change,回调函数可以获取range,oldRange,source
  • editor-change,上述两个事件触发时触发,即使source为silent

除了on方法,还有once用于绑定一次和off方法解绑。

数据模型操作

  • find,寻找DOM节点对应的quill或Blot对象
  • getIndex,返回文档开头到给定Blot的偏移量
  • getLeafBlot,返回给定位置的Blot
  • getLine,返回给定位置整行的Blot
  • getLines,返回给定范围的Blot

拓展

  • debug,设置调试信息级别,info | log | warn | error
  • import,导出quill相关库,输入相对于quill的路径
    1
    2
    3
    4
    5
    var Parchment = Quill.import('parchment');
    var Delta = Quill.import('delta');

    var Toolbar = Quill.import('modules/toolbar');
    var Link = Quill.import('formats/link');
  • register,注册module到quill中,有下面几种用法
    1
    2
    3
    Quill.register(format: Attributor | BlotDefinintion, supressWarning: Boolean = false)
    Quill.register(path: String, def: any, supressWarning: Boolean = false)
    Quill.register(defs: { [String]: any }, supressWarning: Boolean = false)
  • addContainer,新增容器并返回
  • enable/disable,启用、禁用编辑器

delta

delta是quill中最重要的概念。据介绍所说,quill是“第一个”使用delta(结构化数据)这个概念的。不同于其他大多数文本编辑器需要反复执行修改编辑器中的HTML文档。quill维护一个delta数组,使用JSON数据的方式描述了文档的内容。

使用delta一词,并没有问题,因为可以理解成文档本身是由空内容 + delta一点点得到的。delta主要有两个特性:

  • 权威性,delta和对应的生成结果是一一对应的,没有歧义
  • 压缩性,delta中描述的操作是经过压缩后的

delta中的操作可以分为增、删、修改格式,分别对应insertdeleteretain操作。对文本编辑器的一次改动(真实世界中的改动行为)只可能涉及上述三种行为的一种(Quill并不允许Ctrl多处选中)。其中retain的意义类似于光标的移动,它使得这三种操作并不需要使用index描述,便于Quill做优化和压缩。

delta的操作实际上是对parchment进行的,它类似于vdom,使用JS的数据结构对文本编辑器中可能出现的各元素进行了抽象,称作Blot。Blot有scroll,inline、block、text,break几种。父Blot下必须包含至少一个子Blot,而所有的Blot都包含在一个scroll Blot下。文本编辑器中特定格式的文本块都用特定的Blot表示,每个这样的Blot都必须继承自上面的一种Blot类型。就像通过下面的方式继承了Blot,就可以使对应的行内元素得到对应的编辑器样式元素对应起来,并使用在后面的编辑器里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let Inline = Quill.import('blots/inline');

class BoldBlot extends Inline { }
BoldBlot.blotName = 'bold';
BoldBlot.tagName = 'strong';

class ItalicBlot extends Inline { }
ItalicBlot.blotName = 'italic';
ItalicBlot.tagName = 'em';

Quill.register(BoldBlot);
Quill.register(ItalicBlot);

// in your editor

quill.insertText(0, 'Test', { bold: true });
quill.formatText(0, 4, 'italic', true);

类似地,我们定义一个Link Blot。它相比bold,italic不同的是,它需要一个string而不是boolean初始化。因此需要定义createformat两个函数。其中create在构造Blot时使用,value即输入的href,formats将用户的format字段和真实DOM的字段相关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class LinkBlot extends Inline {
static create(value) {
let node = super.create();
// Sanitize url value if desired
node.setAttribute('href', value);
// Okay to set other non-format related attributes
// These are invisible to Parchment so must be static
node.setAttribute('target', '_blank');
return node;
}

static formats(node) {
// We will only be called with a node already
// determined to be a Link blot, so we do
// not need to check ourselves
return node.getAttribute('href');
}
}
LinkBlot.blotName = 'link';
LinkBlot.tagName = 'a';

Quill.register(LinkBlot);

定义引用这样的块级元素时,对应地继承Block Blot即可。和inline Blot不同的是,Block Blot无法嵌套,在对已有的块级元素应用时会替换而不是嵌套绑定在元素上。以Header元素为例,可以指定tagName为一个数组,可以在format时使用1、2的方式指定具体哪种tag。

1
2
3
4
5
6
7
8
9
class HeaderBlot extends Block {
static formats(node) {
return HeaderBlot.tagName.indexOf(node.tagName) + 1;
}
}
HeaderBlot.blotName = 'header';
// Medium only supports two header sizes, so we will only demonstrate two,
// but we could easily just add more tags into this array
HeaderBlot.tagName = ['H1', 'H2'];

类似的,可以在插入embed Blot,这种类型效果是插入在元素中间的新的tag。如Image。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let BlockEmbed = Quill.import('blots/block/embed');

class ImageBlot extends BlockEmbed {
static create(value) {
let node = super.create();
node.setAttribute('alt', value.alt);
node.setAttribute('src', value.url);
return node;
}

static value(node) {
return {
alt: node.getAttribute('alt'),
url: node.getAttribute('src')
};
}
}
ImageBlot.blotName = 'image';
ImageBlot.tagName = 'img';

// 使用时
let range = quill.getSelection(true);
quill.insertText(range.index, '\n', Quill.sources.USER);
quill.insertEmbed(range.index + 1, 'image', {
alt: 'Quill Cloud',
url: 'https://quilljs.com/0.20/assets/images/cloud.png'
}, Quill.sources.USER);
quill.setSelection(range.index + 2, Quill.sources.SILENT);

modules

quill中的module位于quill的应用层。可以通过定制modules,利用quill的功能;或是更改quill内置module,修改quill本身的行为和功能。clipboard、keyboard、history三个module是quill默认加载的。用户完全可以根据业务需求定义自己的module。官网给了简单的例子展示了module的大致骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Quill.register('modules/counter', function(quill, options) {
var container = document.querySelector(options.container);
quill.on('text-change', function() {
var text = quill.getText();
if (options.unit === 'word') {
container.innerText = text.split(/\s+/).length + ' words';
} else {
container.innerText = text.length + ' characters';
}
});
});

var quill = new Quill('#editor', {
modules: {
counter: {
container: '#counter',
unit: 'word'
}
}
});

只需要定义一个可以接收quill对象的函数即可,在函数内部利用quill事件监听即可完成应用层的建设。

Toolbar和ClipBoard

ToolbarClipboard是Quill内置的两个module,对你构建自己的文本编辑器有很大的借鉴意义。

Toolbar用来定制工具栏上的按钮,是自定义编辑器(尤其是业务相关的编辑器)逃不开的一部分。它有几个基本配置:

  • container,放置工具栏的DOM容器
  • handler,点击ToolBar图标时注册的函数,传入Blot的value,通过调用Quill的API完成功能。也可以通过下面方式注册。
1
2
3
// Handlers can also be added post initialization
var toolbar = quill.getModule('toolbar');
toolbar.addHandler('image', showImageUI);

在遇到从别的文本编辑器拷贝内容过来的情况时,需要修改ClipBoard Module中addMatcher的定义。这个方法向ClipBoard中注册了新的Matcher匹配拷贝过来的HTML文本,将之转换为对应的Blot。如:

1
2
3
4
5
6
7
8
quill.clipboard.addMatcher(Node.TEXT_NODE, function(node, delta) {
return new Delta().insert(node.data);
});

// Interpret a <b> tag as bold
quill.clipboard.addMatcher('.custom-class', function(node, delta) {
return delta.compose(new Delta().retain(delta.length(), { bold: true }));
});

或者在configuration中,注入新定义的matcher即可。

1
2
3
4
5
6
7
8
9
10
var quill = new Quill('#editor', {
modules: {
clipboard: {
matchers: [
['B', customMatcherA],
[Node.TEXT_NODE, customMatcherB]
]
}
}
});

参考