在使用quill富文本编辑器时,我们输入文本会被作为类似DOM节点的数据对象存储在内部,渲染时生成相应的DOM节点。这是quill的文档模型Parchment,它提供了多种内容节点类型,如Inline \ Block \ Embed等。

quill 扩展了 Parchment 提供的的基础类型节点,并实现一些操作方法、定义了相关属性。我们可以使用quill扩展的节点再次进行自定义格式内容节点的扩展实现;当然也可以从Parchment提供的基础类型实现自定义内容节点,这需要对Parchment有足够的了解。

在源码目录quill/blots下可以看到quill扩展的节点,如BlockInlineText等。

🌒 基本使用

安装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是数据结构字段的idx.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。它提供了基础的几种节点类型,包括InlineBlotBlockBlotEmbedBlot等。而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模型构内容节点进行操作。

关联文章:

  1. Quill 富文本编辑器实现自定义font-size

hboot
68 声望8 粉丝

爱生活、爱自由