Skip to content

Quill 编辑器(三)复制粘贴 Clipboard

Published:

引言

在一个富文本编辑器中,经常会嵌入很多自定义元素,比如一个 Mermaid 代码块组件、一个带有上传/错误状态的图片组件,或者是一个文件附件。

通常会把这些元素作为一个整体去进行插入、选中、复制、粘贴、删除等行为。

它们自身的 Blot value 中可能包含了很多参数,也都存储在了 DOM 节点上。在不做任何干预的情况下,对这些元素进行框选复制,浏览器会把整个 DOM 节点(以及把它实际生效的样式作为内联样式)写入剪贴板的 text/html 上,并把里面所有的 textContent 写入 text/plain,粘贴时则默认粘贴了后者。纯文本场景下没有问题,如果是自定义元素,这就很难符合预期。

而且由于业务场景过于复杂,可能会要求特定元素不允许复制粘贴(在复制或者粘贴时过滤掉),或者在复制粘贴的过程中要做额外的动作(比如粘贴图片时要做上传处理)。这就需要对编辑器内元素的复制粘贴做额外的处理,支持更定制化的功能。

Quill 编辑器对复制粘贴行为做了统一的处理方案,由内置的 Clipboard 模块负责。

Clipboard 模块的核心功能

Clipboard 模块的核心逻辑只有两点:

  1. 监听复制事件,用选区内容生成 html 和 text,写入剪贴板。
  2. 监听粘贴事件,读取剪贴板的 html 和 text,转换为 delta,后续则进行 Parchment 的更新逻辑。

前者需要实现「选区内容 ==> HTML/Text」,后者需要实现「HTML/Text ==> Delta」。

1、复制:选区内容 ==> HTML/Text

选区是一个很复杂的概念,对于 Quill 这类 L1 等级的富文本编辑起来说,选区的坑太多了。关于选区后续将有一篇文章详细介绍,暂时可以简单理解:

把选区的内容转换为 HTML,实际就是把选区内所有的 Blot 依次转换为 HTML,整体拼接起来。

之前第二篇文章里介绍 Blot 的定义时提到了一个 html 方法,它就是用来把 Blot 实例转换为 HTML 字符串的,默认情况下会返回 outerHTML,特殊场景可以自己定义。

举一个例子,假如有一个自定义的块级的图片组件,它的 Blot 定义如下:

interface CustomImageValue {
  src: string;
  alt: string;
}

class CustomImageBlot extends BlockEmbed {
  static create(value: CustomImageValue) {
    return `<div>
      <div><img src="${value.src}" alt="${value.alt}"></div>
      <div>一些 loading 的 UI</div>
      <div>一个 error 的 UI</div>
    </div>`
  }
  html() {
    return this.domNode.outerHTML;
  }
}

对于这个图片组件来说,简单地使用 outerHTML 是不合适的,因为这样做会把多余的、不相关的数据(loading、error)也写入剪贴板。如果粘贴到其他的富文本编辑器里,很可能无法正确识别,粘贴出错误的数据。

所以需要自定义 html 方法来把无用的数据去除,只保留核心内容,并且应该尽量符合 HTML 规范以便于在其他网站能够完美的粘贴,像这样:

{
  html() {
    const node = this.domNode;
    const src = node.getAttribute('src');
    const width = node.getAttribute('width');
    const alt = node.getAttribute('alt');
    // 其他需要保存的参数,都可以存储在 data-xxx 上
    return `<img src="${src}" width="${width}" alt="${alt}">`;
  }
}

于是复制行为的源码实现如下(稍微修改了一下):

quill.root.addEventListener('copy', (e) => {
  if (e.defaultPrevented) return;
  // 拦截默认行为,这样浏览器就不会自动写入剪贴板数据
  e.preventDefault();
  // 1、获取选区内容
  const range = quill.getSelection();
  if (range == null) return;
  // 2、获取选区内容对应的纯文本
  const text = quill.getText(range);
  // 3、获取选区格式
  const formats = quill.getFormat(range);
  // 4、获取选区对应的 HTML
  const html = quill.getSemanticHTML(range.index, range.length, formats);
  // 5、写入剪贴板
  e.clipboardData?.setData('text/plain', text);
  e.clipboardData?.setData('text/html', html);
});

const getText = (index: number, length: number): string => {
  return this.getContents(index, length)
    .filter((op) => typeof op.insert === 'string')
    .map((op) => op.insert)
    .join('');
}

const covertHTML = (blot, index, length, formats) => {
  // 有自定义的 html 方法
  if ('html' in blot && typeof blot.html === 'function') {
    return blot.html(index, length);
  }
  // 文本节点
  if (blot instanceof TextBlot) {
    return blot.value().slice(index, index + length);
  }
  // 嵌套的父节点,需要拼接子节点的 HTML 字符串,并去掉多余的包裹层
  if (blot instanceof ParentBlot) {
    const parts: string[] = [];
    blot.children.forEachAt(index, length, (child, offset, childLength) => {
      parts.push(convertHTML(child, offset, childLength));
    });
    const { outerHTML, innerHTML } = blot.domNode as Element;
    const [start, end] = outerHTML.split(`>${innerHTML}<`);
    return `${start}>${parts.join('')}<${end}`;
  }
  return blot.domNode instanceof Element ? blot.domNode.outerHTML : '';
}

const getSemanticHTML = (index: number, length: number, formats: any) => {
  return convertHTML(this.scroll, index, length, formats);
}

其中 getSemanticHTML 的逻辑需要实现一个递归的 convertHTML 方法,支持获取某个 blot 中从 index 到 index + length 的 HTML 字符串。

上面的例子仅保留了一些核心的分支逻辑,实际实现时会有更多场景需要处理。

2、粘贴:HTML/Text ==> Delta

粘贴的核心逻辑如下:

quill.root.addEventListener('paste', (e) => {
  if (e.defaultPrevented || !this.quill.isEnabled()) return;
  e.preventDefault();
  const range = this.quill.getSelection(true);
  if (range == null) return;
  // 获取剪贴板中的 html 和 text
  const html = e.clipboardData?.getData('text/html');
  const text = e.clipboardData?.getData('text/plain');
  const files = Array.from(e.clipboardData?.files || []);
  if (!html && files.length > 0) {
    // 此处是单独处理粘贴文件的场景,比如粘贴图片
    this.quill.uploader.upload(range, files);
    return;
  }
  const formats = this.quill.getFormat(range.index);
  // 将 html 和 text 转换为 Delta 对象,并插入编辑器
  const pastedDelta = this.convert({ text, html }, formats);
  const delta = new Delta()
    .retain(range.index)
    .delete(range.length)
    .concat(pastedDelta);
  this.quill.updateContents(delta, Quill.sources.USER);
})

此时需要考虑几种情况,以及根据实际业务场景增加对粘贴 markdown 的处理:

Quill 内部维护了一个 CLIPBOARD_CONFIG 的数组,用来定义单个 DOM 如何转换为单个 Delta。

对每一个节点都按照下面这些规则进行匹配,直到匹配成功则终止,再递归处理父节点。本质上是对剪贴板 HTML 最外层 DOM 节点进行的后序遍历。

const CLIPBOARD_CONFIG: [Selector, Matcher][] = [
  [Node.TEXT_NODE, matchText],
  [Node.TEXT_NODE, matchNewline],
  ['br', matchBreak],
  [Node.ELEMENT_NODE, matchNewline],
  [Node.ELEMENT_NODE, matchBlot],
  [Node.ELEMENT_NODE, matchAttributor],
  [Node.ELEMENT_NODE, matchStyles],
  ['li', matchIndent],
  ['ol, ul', matchList],
  ['pre', matchCodeBlock],
  ['tr', matchTable],
  ['b', createMatchAlias('bold')],
  ['i', createMatchAlias('italic')],
  ['strike', createMatchAlias('strike')],
  ['style', matchIgnore],
];

上面的规则数组中有三类:

前两者用来处于 Quill 自己产生的 HTML,最后一种则用来匹配一些通用的标签,它通常来自外部其他富文本编辑器。

其中 matchBlot 方法能覆盖绝大多数场景,包括自定义的 Blot,通常情况下不需要为新增的 Blot 单独增加 match 方法。

有一个比较常见的需求:编辑器里有一种附件元素,它是当前用户的文件,要求可以粘贴到自己的文档中,但不能粘贴到其他人的文档中。

这种情况则需要将该元素写入剪贴板(自定义 html 方法),在粘贴时根据当前页面的登录状态进行判断是否要过滤掉(自定义 match 方法)。

兼容性问题

写入剪贴板有两种 API,出于兼容性考虑一般需要同时使用。

第一种是 navigator.clipboard.write,它是一个异步任务,且限制在 HTTPS 环境下使用。

它可以直接往剪贴板中写入 text/htmltext/plain 两种类型的数据。

async function copyToClipboard1(html, text) {
  if (navigator.clipboard) {
    const htmlBlob = new Blob([html], { type: 'text/html' });
    const textBlob = new Blob([text], { type: 'text/plain' });
    const data = new ClipboardItem({ 'text/html': htmlBlob'text/plain': textBlob });
    await navigator.clipboard.write([data]);
  }
}

第二种是 execCommand('copy'),虽然已废弃,但(除了 Safari 外)兼容性比较好,是一个同步任务。这种方式下需要先设置选区,再通过监听 copy 事件的逻辑写入剪贴板。

async function copyToClipboard2(range) {
  // 手动设置选区
  quill.setSelection(range);
  // 执行 copy
  const success = document.execCommand('copy');
}

复制的场景也有很多种:

在前两种场景下,copy 事件是由浏览器触发的,只需要监听并重写 e.clipboardData 即可。

最后一种场景,需要在点击事件的回调中手动触发复制行为,此时就需要结合上面两种 API 一起实现:

const copy = (html, text) => {
  if (navigator.clipboard) {
    copyToClipboard1(html, text);
  } else {
    const range = quill.getSelection();
    copyToClipboard2(range);
  }
}

总结

Quill 的 Clipboard 模块主要负责处理所有复制粘贴的行为,它会在复制时将选区内容转换为可以自定义的 HTML 结构,在粘贴时再通过一系列规则将 HTML 转换为 Delta。

通过自定义 Blot 的 html 方法、新增特定元素的 match 规则,可以实现各种场景的特殊需求。