在使用quill
富文本编辑器时,我们输入文本会被作为类似DOM节点的数据对象存储在内部,渲染时生成相应的DOM节点。这是quill
的文档模型Parchment
,它提供了多种内容节点类型,如Inline
\ Block
\ Embed
等。
quill
扩展了 Parchment
提供的的基础类型节点,并实现一些操作方法、定义了相关属性。我们可以使用quill
扩展的节点再次进行自定义格式内容节点的扩展实现;当然也可以从Parchment
提供的基础类型实现自定义内容节点,这需要对Parchment
有足够的了解。
在源码目录quill/blots
下可以看到quill
扩展的节点,如Block
、Inline
、Text
等。
🌒 基本使用
安装quill
,现在版本是v2.0.2
:
$> npm i quill
创建quill实例,加载基本的样式、设置主题snow
。
<template>
<div class="rich-editor">
<div ref="root"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import Quill from "quill";
// 核心样式
// import "quill/dist/quill.core.css";
// 主题
import "quill/dist/quill.snow.css";
defineOptions({
name: "RichEditor",
});
const root = ref<HTMLElement>();
let editor: Quill | null = null;
onMounted(() => {
editor = new Quill(root.value!, {
modules: { toolbar: true },
theme: "snow",
});
});
</script>
这是基础示例的完整代码,之后的更改不再贴重复代码。
🌓 需求背景
实现在输入内容/
或者{
时触发弹窗选择数据后插入到富文本中,节点插入的格式为{{id.x.y}}
表示选择的结构数据。但是直接插入这种格式数据展示不够直观,我们需要格式内容展示自定义样式。
这是dify
的样子,有做过流程编排的同学应该熟悉。我们需要映射数据结构字段为中文,并展示在富文本中。
首先实现插入原始格式的内容节点,这个还是比较好实现的。我们通过监听输入事件,获取到输入内容判断末尾是包含了/
或者{
,然后展示弹出层提供数据选择。需要注意的是,我们在弹出层选择数据时,富文本失焦,插入选择的内容就不知道放在哪了,所以我们需要存储当前光标位置,等选择数据后插入到富文本中。
onMounted(() => {
// ...
// 监听值变化
editor.on(
"text-change",
(_delta: Delta, _oldContents: Delta, _source: string) => {
reference.value?.close();
// 用户操作
if (source !== "user") return;
// 鼠标位置
const range = editor!.getSelection()!;
for (const op of delta.ops) {
if (typeof op.insert == "string" && ["/", "{"].includes(op.insert)) {
handleShowReference();
// 存储当前光标位置
cursorIndex.value = range.index;
}
}
}
);
});
数据选择弹出的代码不必在此处展示了,调用handleShowReference()
展示弹出层。弹出选择后我们点击选择数据,获取到数据后,插入到富文本中。
按照需求,我们插入的数据格式为{{id.x.y}}
,其中id
是数据结构字段的id
,x.y
是字段名称。
const handlePickParam = ({
nodeId,
name,
}: {
nodeId: string;
name: string;
}) => {
reference.value?.close();
// 插入的数据模板
const template = `{{${nodeId}.${name}}}`;
cursorIndex.value -= 1;
// 插入数据,光标位置为存储的光标位置
editor!.insertText(cursorIndex.value, template);
// 删掉之前作为触发弹出层的一个字符
cursorIndex.value += template.length;
editor!.deleteText(cursorIndex.value, 1);
};
可以看看效果,基本实现了需求的第一步,插入了指定格式的内容数据;
这里光标的位置获取有个BUG 🐞 ,在输入第一个字符时/
触发选择时,获取到的光标为0
,导致我们删除和插入的字符位置不对,导致数据不正确。
这里可以先处理下,光标位置不能小于0
cursorIndex.value -= 1;
// 第一行第一个字符光标位置 hack
if (cursorIndex.value < 0) cursorIndex.value = 0;
// 其他行第一个字符光标位置 hack
if (editor!.getText(cursorIndex.value,3).startsWith("\n")) {
cursorIndex.value += 1;
}
我们在选择数据后,富文本是失焦的,我们重新调用focus()
方法,让富文本重新获取焦点。
// 移动光标到后一个位置
editor!.setSelection(
cursorIndex.value + template.length,
Quill.sources.SILENT
);
🌔 实现自定义格式
实现了需求要求的格数数据后,继续实现进一步的需求,将插入的格式内容展示为自定义样式。我们内容格式不需要独占一行,所以需要继承Inline
类型。
quill
内部使用Parchment
一个文档模型定义内容节点,最小单元称之为Blots
。它提供了基础的几种节点类型,包括InlineBlot
、 BlockBlot
、EmbedBlot
等。而quill
又继承扩展了这些基础类型,实现了富文本需要的功能。我们基于quill
扩展的类型Inline
实现自定义的节点;当然也可以从Parchment
提供的类型实现自定义节点。
在quill/blots
目录下找到Inline
类型的节点,继承实现一些方法。因为我们需要格式内容,要改变它的DOM结构,按照我们的样式去渲染,所以覆盖重写create()
方法,它是一个静态方法,返回一个DOM节点。
还有一些静态属性,我们在自定义节点时设置来标识节点类型,其他不需要的则不需要再次声明:
blotName
扩展节点名称className
节点类名,用于样式控制tagName
标签名
直接贴定义的ReferenceBlock
节点代码,重写了create()
方法:
import Inline from "quill/blots/inline";
class ReferenceBlock extends Inline {
static blotName: string = "reference-block";
static className: string = "reference-block";
static tagName: string = "div";
static create(value: { id: string; name: string,label:string }) {
const root = super.create();
// 将占位符值作为节点的属性保存
root.dataset.id = value.id;
root.dataset.name = value.name;
root.setAttribute("contenteditable", "false"); // 禁用编辑
const span = document.createElement("span");
span.classList.add("reference-node-name");
span.textContent = value.label;
root.appendChild(span);
const label = document.createElement("span");
label.classList.add("reference-node-key");
label.textContent = "·" + value.name;
root.appendChild(label);
return root;
}
}
自定义节点需要注册到quill
中才能使用;定义了我们的节点不可编辑contenteditable=false
,这样嵌入的格式内容不受影响。
// 注册自定义块
Quill.register(ReferenceBlock);
注册完,就可以使用自定义节点了,对于非纯文本内容,在插入时需要使用insertEmbed()
方法。可以看一下insertEmbed()
方法的签名
// 插入位置、格式类型、输入值、来源
// 返回值为 Delta对象
insertEmbed(index: number, type: string, value: any, source: string = 'api'): Delta
我们修改之前的插入方法handlePickParam
,使用insertEmbed()
方法插入自定义节点。
const handlePickParam = ({
nodeId,
name,
}: {
nodeId: string;
name: string;
}) => {
reference.value?.close();
cursorIndex.value -= 1;
// 第一行第一个字符光标位置 hack
if (cursorIndex.value < 0) cursorIndex.value = 0;
// 其他行第一个字符光标位置 hack
if (editor!.getText(cursorIndex.value,3).startsWith("\n")) {
cursorIndex.value += 1;
}
// 查找节点数据
const node = findNode(nodeId)
// 插入数据,光标位置为存储的光标位置
editor!.insertEmbed(cursorIndex.value, "reference-block", {
id: nodeId,
name,
label: node.label
});
// 删掉之前作为触发的一个字符
cursorIndex.value += 1;
editor!.deleteText(cursorIndex.value, 1);
};
因为div
元素自身是一个块级元素,所以需要设置样式display:inline-block
更改为内联元素。
可以看到改动的地方,插入数据调用了insertEmbed()
,将选择的数据传给节点。在create
方法中将数据字段作为属性设置到节点上,后续会使用到;现在测试下看看展示效果以及内容DOM节点:
可以看到富文本内容展示的基本符合了我们的需求,但是生成的内容DOM结构并不对,我们是在内部分两个span
放置文本内容,为了便于我们给每个部分设置不同的样式,但是实际并没有生成这两个span
,而是直接将文本内容作为父节点的内容。
这是因为quill
对于Inline
的限制,Inline
适合处理纯文本内容的格式;我们插入子节点DOM,改为继承Embed
,其他的不用调整;
import Embed from "quill/blots/embed";
class ReferenceBlock extends Embed {
// ... 代码
}
再次测试,可以看到生成的节点符合了我们的需求:
查看quill/blots/embed
可以看到这部分结构的实现,也为我们解决更复杂的问题提供了一个方式。Embed
创建了一个span
元素,并设置了contenteditable
,所以我们之前自定义元素可以移除掉属性。
const GUARD_TEXT = '\uFEFF';
class Embed extends EmbedBlot {
constructor(scroll: ScrollBlot, node: Node) {
super(scroll, node);
this.contentNode = document.createElement('span');
this.contentNode.setAttribute('contenteditable', 'false');
Array.from(this.domNode.childNodes).forEach((childNode) => {
this.contentNode.appendChild(childNode);
});
this.leftGuard = document.createTextNode(GUARD_TEXT);
this.rightGuard = document.createTextNode(GUARD_TEXT);
this.domNode.appendChild(this.leftGuard);
this.domNode.appendChild(this.contentNode);
this.domNode.appendChild(this.rightGuard);
}
}
实现了选择数据嵌入了自定义的格式内容,此时富文本失焦状态,需要使富文本聚焦。自定义嵌入的格式默认长度为1
,所以我们聚焦时,只需要传入当前记录的光标位置的下一个位置即可。
暂时没搞懂为什么要加leftGuard
这种字符节点。
// 移动光标到后一个位置
editor!.setSelection(cursorIndex.value + 1, Quill.sources.API);
光标在嵌入格式内容之后没有问题,但是在输入中文时,选择了输入的中文后,光标并没有更新;输入英文光标是正常的。就算不去自动聚焦,手动聚焦也有问题,感觉是个bug 🐞
完成了需要的功能,最后追加一下自定义元素的样式即可。
🌕 收尾工作
完成了交互功能,还需要考虑信息保存,由于quill
并不会保留原始的数据信息,这就需要我们手动去处理。quill
处理内容的数据结构为Delta
,它存储了针对内容格式的一些信息。
我们在定义ReferenceBlock
时,重写了 create
方法,并把一些关键属性绑定到了DOM节点上。这样只要我们可以拿到节点DOM就可以拿到数据。
比如前面我们调用insertEmbed
它会返回一个Delta
对象,来看看返回的内容有什么,我们将对象输出:
{
"ops": [
{
"insert": {
"reference-block": true
}
}
]
}
标识了当前操作是一个插入insert
操作,并且格式应用的是我们自定义的reference-block
。其他信息没有了,Blot
定义了value
方法返回当前节点的值,我们覆盖方法定义我们需要的数据。
class ReferenceBlock extends Embed {
static value(node: HTMLElement) {
return {
id: node.dataset.id,
name: node.dataset.name,
label: node.dataset.label,
};
}
}
再次执行,输出插入的Delta
对象,可以看到结构已经变成我们定义的格式了,这样在后续的处理就变了容易了。
我们要怎么获取值呢,quill
提供了API getContents
方法用来获取编辑器内容,并且包含了格式内容信息。我们输入一些文本、增加一些删除操作、再添加我们自定义的内容格式;来看看返回的数据是什么样的
{
"ops": [
{
"insert": "你好啊 "
},
{
"insert": {
"reference-block": {
"id": "001",
"name": "name",
"label": "开始"
}
}
},
{
"insert": " nic\n"
}
]
}
看到数据结构就很容易处理了,我们遍历数据,判断是否插入的是reference-block
格式,然后取出对象里的字段值,然后字符串拼接传递到后端你好啊 {{001.name}} nic
既然有保存,那就有回显,我们初始化拿到之前保存的内容后,需要回显到富文本中,且符合模式{{*.**.**}}
的我们需要特殊处理,应用我们自定义格式。
当然,如果能说服后端直接存储Delta
对象,我们就不用自己处理了。通常后端需要的字符串,将Delta
对象传给他们,会造成沟通理解的成本,还是自己处理吧。
onMounted(() => {
// 手动格式化文本
const delta = formatContent(value.value);
// 插入文本内容
editor.setContents(delta)
})
formatContent()
是自定义方法处理字符串,构造Delta
对象并通过setContents()
方法将内容插入到富文本中。如果我们富文本还应用到其他内容格式功能,比如Bold
\ Italic
等工具栏中的功能,那最好再加个字段保存getContents()
返回的格式内容数据。
quill
可扩展支持自定义格式的能力极大的增强了富文本内容的丰富性。而且内容操作、格式都有迹可循,我们不直接操作DOM,通过Parchment
模型构内容节点进行操作。
关联文章:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。