引言
在一个富文本编辑器中,经常会嵌入很多自定义元素,比如一个 Mermaid 代码块组件、一个带有上传/错误状态的图片组件,或者是一个文件附件。
通常会把这些元素作为一个整体去进行插入、选中、复制、粘贴、删除等行为。
它们自身的 Blot value 中可能包含了很多参数,也都存储在了 DOM 节点上。在不做任何干预的情况下,对这些元素进行框选复制,浏览器会把整个 DOM 节点(以及把它实际生效的样式作为内联样式)写入剪贴板的 text/html
上,并把里面所有的 textContent
写入 text/plain
,粘贴时则默认粘贴了后者。纯文本场景下没有问题,如果是自定义元素,这就很难符合预期。
而且由于业务场景过于复杂,可能会要求特定元素不允许复制粘贴(在复制或者粘贴时过滤掉),或者在复制粘贴的过程中要做额外的动作(比如粘贴图片时要做上传处理)。这就需要对编辑器内元素的复制粘贴做额外的处理,支持更定制化的功能。
Quill 编辑器对复制粘贴行为做了统一的处理方案,由内置的 Clipboard 模块负责。
Clipboard 模块的核心功能
Clipboard 模块的核心逻辑只有两点:
- 监听复制事件,用选区内容生成 html 和 text,写入剪贴板。
- 监听粘贴事件,读取剪贴板的 html 和 text,转换为 delta,后续则进行 Parchment 的更新逻辑。
前者需要实现「选区内容 ==> HTML/Text」,后者需要实现「HTML/Text ==> Delta」。
1、复制:选区内容 ==> HTML/Text
选区是一个很复杂的概念,对于 Quill 这类 L1 等级的富文本编辑起来说,选区的坑太多了。关于选区后续将有一篇文章详细介绍,暂时可以简单理解:
- 选区就是用户鼠标框选的区域,在 Quill 中简化为一个 Range 对象
- index: 选区的起始位置
- length: 选区的长度
- 只有光标的情况下,选区长度为 0
把选区的内容转换为 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 的处理:
- 有 html,有/无 text:html ==> delta
- 无 html,有 text:识别是否是 markdown 格式
- 是:markdown ==> delta
- 否:直接转换为
insert: text
- 无 html,无 text:空数据
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/html
、text/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');
}
复制的场景也有很多种:
- 通过键盘事件 Ctrl/Command + C
- 通过右键菜单复制
- 自定义按钮的点击事件复制
在前两种场景下,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 规则,可以实现各种场景的特殊需求。