找回密码
 立即注册
首页 业界区 业界 富文本编辑器剪贴板模块基石-序列化与反序列化 ...

富文本编辑器剪贴板模块基石-序列化与反序列化

热琢 2025-6-2 23:54:19
在富文本编辑器中,序列化与反序列化是非常重要的环节,其涉及到了编辑器的内容复制、粘贴、导入导出等模块。当用户在编辑器中进行复制操作时,富文本内容会被转换为标准的HTML格式,并存储在剪贴板中。而在粘贴操作中,编辑器则需要将这些HTML内容解析并转换为编辑器的私有JSON结构,以便于实现跨编辑器内容的统一管理。
描述

我们平时在使用一些在线文档编辑器的时候,可能会好奇一个问题,为什么我们能够直接把格式复制出来,而不仅仅是纯文本,甚至于说从浏览器中复制内容到Office Word都可以保留格式。这看起来是不是一件很神奇的事情,不过当我们了解到剪贴板的基本操作之后,就可以了解这其中的底层实现了。
说到剪贴板的操作,在执行复制行为的时候,我们可能会认为复制的就是纯文本,然而显然光靠复制纯文本我们是做不到上述的功能。所以实际上剪贴板是可以存储复杂内容的,那么在这里我们以Word为例,当我们从Word中复制文本时,其实际上是会在剪贴板中写入这么几个key值:
  1. text/plain
  2. text/html
  3. text/rtf
  4. image/png
复制代码
看着text/plain是不是很眼熟,这显然就是我们常见的Content-Type或者称作MIME-Type,所以说我们是不是可以认为剪贴板是一个Record的结构类型。但是别忽略了我们还有一个image/png类型,因为我们的剪贴板是可以直接复制文件的,所以我们常用的剪贴板类型就是Record,例如此时复制这段文字在剪贴板中就是如下内容
  1. text/plain
  2. 例如此时复制这段文字在剪贴板中就是如下内容
  3. text/html
  4. <meta charset="utf-8"><strong >例如此时复制这段文字</strong><em >在剪贴板中就是如下内容</em>
复制代码
那么我们执行粘贴操作的时候就很明显了,只需要从剪贴板里读取内容就可以。例如我们从语雀复制内容到飞书中时,在语雀复制的时候会将text/plain以及text/html写入剪贴板,在粘贴到飞书的时候就可以首先检查是否有text/html的key,如果有的话就可以读取出来,并且将其解析成为飞书自己的私有格式,就可以通过剪贴板来保持内容格式粘贴到飞书了。而如果没有text/html的话,就直接将text/plain的内容写到私有的JSON数据即可。
此外,我们还可以考虑到一个问题,在上边的例子中实际上我们是复制时需要将JSON转到HTML字符串,在粘贴时需要将HTML字符串转换为JSON,这都是需要进行序列化与反序列化的,是需要有性能消耗以及内容损失的,所以是不是能够减少这部分消耗。通常来说如果是在应用内直接直接粘贴的话,可以直接通过剪贴板的数据直接compose到当前的JSON即可,这样就可以更完整地保持内容以及减少对于HTML解析的消耗。例如在飞书中,会有docx/text的独立clipboard key以及data-lark-record-data作为独立JSON数据源。
那么至此我们已经了解到剪贴板的工作原理,紧接着我们就来聊一聊如何进行序列化的操作。说到复制我们可能通常会想到clipboard.js,如果需要兼容性比较高的话(IE)可以考虑,但是如果需要在现在浏览器中使用的话,则可以直接考虑使用HTML5规范的API完成,在浏览器中关于复制的API常用的有两种,分别是document.execCommand("copy")以及navigator.clipboard.write/writeText。
  1. document.execCommand("selectAll");
  2. const res = document.execCommand("copy");
  3. console.log(res); // true
复制代码
  1. const dataItems: Record<string, Blob> = {};
  2. for (const [key, value] of Object.entries(data)) {
  3.   const blob = new Blob([value], { type: key });
  4.   dataItems[key] = blob;
  5. }
  6. navigator.clipboard.write([new ClipboardItem(dataItems)])
复制代码
而对于序列化即粘贴行为,则存在document.execCommand("paste")以及navigator.clipboard.read/readText可用。但是需要注意的是execCommand这个API的调用总是会失败,clipboard.read则需要用户主动授权。关于这个问题,我们在先前通过浏览器扩展对可信事件的研究也已经有过结论,在扩展中即使保持清单中的clipboardRead权限声明,也无法直接读取剪贴板,必须要在Content Script甚至chrome.debugger中才可以执行。
  1. document.addEventListener("paste", (e) => {
  2.   const data = e.clipboardData;
  3.   console.log(data);
  4. });
  5. const res = document.execCommand("paste");
  6. console.log(res); // false
复制代码
  1. navigator.clipboard.read().then(res => {
  2.   for (const item of res) {
  3.     item.getType("text/html").then(console.log).catch(() => null)
  4.   }
  5. });
复制代码
当然这里并不是此时研究的重点,我们关注的是内容的序列化与反序列化,即在富文本编辑器的复制粘贴模块的设计。当然这个模块还会有更广泛的用途,例如序列化的场景有交付Word文档、输出Markdown格式等,反序列的场景有导入Markdown文档等。而我们对于这个模块的设计,则需要考虑到以下几个问题:

  • 插件化,编辑器中的模块本身都是插件化的,那么关于剪贴板模块的设计自然也需要能够自由扩展序列化/反序列化的格式。特别是在需要精确适配编辑器例如飞书、语雀等的私有格式时,需要能够自由控制相关行为。
  • 普适性,由于富文本需要实现DOM与选区MODEL的映射,因此生成的DOM结构通常会比较复杂。而当我们从文档中复制内容到剪贴板时,我们会希望这个结构是更规范化的,以便粘贴到其他平台例如飞书、Word等时会有更好的解析。
  • 完整性,当执行序列化与反序列时,希望能够保持内容的完整性,即不会因为这个的过程而丢失内容,这里相当于对性能做出让步而保持内容完整。而对于编辑器本身的格式则关注性能,由于实际注册的模块一致,希望能够直接应用数据而不需要走整个解析过程。
那么本文将会以slate为例,处理嵌套结构的剪贴板模块设计,并且以quill为例,处理扁平结构的剪贴板模块设计。并且以飞书文档的内容为例,分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,分类型进行序列化与反序列化的设计。
嵌套结构

slate的基本数据结构是树形结构的JSON类型,相关的DEMO实现都在https://github.com/WindRunnerMax/DocEditor中。我们先以标题与加粗的格式为例,描述其基础内容结构:
  1. [
  2.   { children: [{ text: "Editor" }], heading: { type: "h1", id: "W5xjbuxy" } },
  3.   { children: [{ text: "加粗", bold: true }, { text: "格式" }] },
  4. ];
复制代码
实际上slate的数据结构形式非常类似于DOM结构的嵌套格式,甚至于DOM结构与数据结构是完全一一对应的,例如在渲染Embed结构中的零宽字符渲染时也会在数据结构中存在。因此在实现序列化与反序列化的过程中,理论上我们是可以直接实现其JSON结构完全对应为DOM结构的转换。
然而完全对应的情况只是理想情况下,富文本编辑器对于内容的实际组织形式可能会多种多样,例如实现引用块结构时,外层包裹的blockquote标签可能是数据结构本身存在,也可能是渲染时根据行属性动态渲染的,这种情况下就不能直接从数据结构的层面上将其序列化为完整的HTML。
  1. // 结构渲染
  2. [
  3.   {
  4.     blockquote: true,
  5.     children:[
  6.       { children: [{ text: "引用块 Line 1" }] },
  7.       { children: [{ text: "引用块 Line 2" }] },
  8.     ]
  9.   }
  10. ];
  11. // 动态渲染
  12. [
  13.   { children: [{ text: "引用块 Line 1" }], blockquote: true },
  14.   { children: [{ text: "引用块 Line 2" }], blockquote: true },
  15. ];
复制代码
此外,我们实现的编辑器必然是需要插件化的,在剪贴板模块中我们无法准确得知插件究竟是如何组织数据结构的。而在富文本编辑器中有着不成文的规矩,我们写入剪贴板的内容需要是尽可能规范化的结构,否则就无法跨编辑器粘贴内容。因此我们如果希望能够保证规范化的数据,就需要在剪贴板模块提供基本的序列化与反序列化的接口,而具体的实现则归于插件本身处理。
那么基于这个基本理念,我们首先来看序列化的实现,即JSON结构到HTML的转换过程。先前我们也提到了,对于编辑器本身的格式则关注性能,由于实际注册的模块一致,希望能够直接应用数据而不需要走整个解析过程,因此我们还需要在剪贴板中额外写入application/x-doc-editor的key,用来直接存储Fragment数据。
  1. {
  2.   "text/plain": "Editor\n加粗格式",
  3.   "text/html": "<h1 id="W5xjbuxy">Editor</h1><strong>加粗</strong>格式",
  4.   "application/x-doc-editor": '[{"children":[{"text":"Editor"}],"heading":{"type":"h1","id":"W5xjbuxy"}},{"children":[{"text":"加粗","bold":true},{"text":"格式"}]}]',
  5. }
复制代码
我们接下来需要设想下如何将内容写入到剪贴板,以及实际触发的场景。除了常见的Ctrl+C来触发复制行为外,用户还有可能希望通过按钮来触发复制行为,例如飞书就可以通过工具栏复制整个行/块结构,因此我们不能直接通过OnCopy事件的clipboardData来写数据,而是需要主动触发额外的Copy事件。
前边也提到了navigator.clipboard.write同样可以写入剪贴板,调用这个API是不需要真正触发Copy事件的,但是当我们使用这个方法写入数据的时候,可能会抛出异常。此外这个API必须要在HTTPS环境下才能使用,否则会完全没有这个函数的定义。
在下面的例子中需要焦点在document上,需要在延迟时间内点击页面,否则会抛出DOMException。而即使当我们焦点在页面上,执行后同样会抛出DOMException,从抛出的异常来看是因为application/x-doc-editor类型不被支持。
  1. (async () => {
  2.   await new Promise((resolve) => setTimeout(resolve, 3000));
  3.   const params = {
  4.     "text/plain": "Editor",
  5.     "text/html": "Editor",
  6.     "application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]',
  7.   }
  8.   const dataItems = {};
  9.   for (const [key, value] of Object.entries(params)) {
  10.     const blob = new Blob([value], { type: key });
  11.     dataItems[key] = blob;
  12.   }
  13.   // DOMException: Type application/x-doc-editor not supported on write.
  14.   navigator.clipboard.write([new ClipboardItem(dataItems)]);
  15. })();
复制代码
因为这个API不支持我们写入自定义的类型,因此我们就需要主动触发Copy事件来写入剪贴板,虽然我们同样可以将这个字段的数据作为HTML的某个属性值写入text/html中,但是我们这里还是将其独立出来处理。那么以同样的数据,我们使用document.execCommand写入剪贴板的方式就需要新建textarea元素来实现。
  1. const data = {
  2.   "text/plain": "Editor",
  3.   "text/html": "Editor",
  4.   "application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]',
  5. }
  6. const textarea = document.createElement("textarea");
  7. textarea.addEventListener("copy", event => {
  8.   for (const [key, value] of Object.entries(data)) {
  9.     event.clipboardData && event.clipboardData.setData(key, value);
  10.   }
  11.   event.stopPropagation();
  12.   event.preventDefault();
  13. });
  14. textarea.style.position = "fixed";
  15. textarea.style.left = "-999px";
  16. textarea.style.top = "-999px";
  17. textarea.value = data["text/plain"];
  18. document.body.appendChild(textarea);
  19. textarea.select();
  20. document.execCommand("copy");
  21. document.body.removeChild(textarea);
复制代码
当然这里我们能够很明显地看到由于textarea.select,我们原本的编辑器焦点会丢失。因此这里我们还需要注意,在执行复制的时候需要记录当前的选区值,在写入剪贴板之后先将焦点置于编辑器,之后再恢复选区。
接下来我们来处理插件化的定义,这里的Context非常简单,只需要记录当前正在处理的Node以及当前已经处理过后的html节点即可。而在插件中我们需要实现serialize方法,用来将Node序列化为HTML,willSetToClipboard则是Hook定义,当即将写入剪贴板时会被调用。
  1. // packages/core/src/clipboard/utils/types.ts
  2. /** Fragment => HTML */
  3. export type CopyContext = {
  4.   /** Node 基准 */
  5.   node: BaseNode;
  6.   /** HTML 目标 */
  7.   html: Node;
  8. };
  9. // packages/core/src/plugin/modules/declare.ts
  10. abstract class BasePlugin {
  11.   /** 将 Fragment 序列化为 HTML  */
  12.   public serialize?(context: CopyContext): void;
  13.   /** 内容即将写入剪贴板 */
  14.   public willSetToClipboard?(context: CopyContext): void;
  15. }
复制代码
既然我们的具体转换是在插件中实现的,那么我们主要的工作就是调度插件的执行了。为了方便处理数据,我们这里就不使用Immutable的形式来处理了,我们的Context对象是整个调度过程中保持一致的,即插件中我们所有的方法都是原地处理的。那么调度的方式就直接通过plugin组件调度,调用后从context中获取html节点即可。
  1. // packages/core/src/plugin/modules/declare.ts
  2. public call<T extends CallerType>(key: T, payload: CallerMap[T], type?: PluginType) {
  3.   const plugins = this.current;
  4.   for (const plugin of plugins) {
  5.     try {
  6.       // @ts-expect-error payload match
  7.       plugin[key] && isFunction(plugin[key]) && plugin[key](payload);
  8.     } catch (error) {
  9.       this.editor.logger.warning(`Plugin Exec Error`, plugin, error);
  10.     }
  11.   }
  12.   return payload;
  13. }
  14. const context: CopyContext = { node: child, html: textNode };
  15. this.plugin.call(CALLER_TYPE.SERIALIZE, context);
  16. value.appendChild(context.html);
复制代码
那么重点的地方就是我们设计的serialize调度方法,我们这里的核心思想是: 当处理到文本行时,我们创建一个空的Fragment节点作为行节点,然后迭代每个文本值,取出当前行的每个Text值创建文本节点,以此创建context对象,然后调度PLUGIN_TYPE.INLINE级别的插件,将序列化后的HTML节点插入到行节点中。
  1. // packages/core/src/clipboard/modules/copy.ts
  2. if (this.reflex.isTextBlock(current)) {
  3.   const lineFragment = document.createDocumentFragment();
  4.   current.children.forEach(child => {
  5.     const text = child.text || "";
  6.     const textNode = document.createTextNode(text);
  7.     const context: CopyContext = { node: child, html: textNode };
  8.     this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.INLINE);
  9.     lineFragment.appendChild(context.html);
  10.   });
  11. }
复制代码
然后针对每个行节点,我们同样需要调度PLUGIN_TYPE.BLOCK级别的插件,将处理过后的内容放置于root节点中,并将内容返回。这样我们就完成了最基本的文本行的序列化操作,这里我们在DOM节点上加入了额外的标识,这样可以帮助我们在反序列化的时候能够幂等地处理。
  1. // packages/core/src/clipboard/modules/copy.ts
  2. const root = rootNode || document.createDocumentFragment();
  3. // ...
  4. const context: CopyContext = { node: current, html: lineFragment };
  5. this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK);
  6. const lineNode = document.createElement("div");
  7. lineNode.setAttribute(LINE_TAG, "true");
  8. lineNode.appendChild(context.html);
  9. root.appendChild(lineNode);
复制代码
在基本的行结构处理完成后,还需要关注外层的Node节点,这里的数据处理方式与行节点类似。但是这里需要注意的是,这里是递归的结构处理,那么这里的JSON结构执行顺序就是深度优先遍历,即先处理文本节点以及行节点,然后再处理外部的块结构,由内而外地处理,由此来保证整个DOM树形结构的处理。
  1. // packages/core/src/clipboard/modules/copy.ts
  2. if (this.reflex.isBlock(current)) {
  3.   const blockFragment = document.createDocumentFragment();
  4.   current.children.forEach(child => this.serialize(child, blockFragment));
  5.   const context: CopyContext = { node: current, html: blockFragment };
  6.   this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK);
  7.   root.appendChild(context.html);
  8.   return root as T;
  9. }
复制代码
而对反序列化的处理则相对简单,Paste事件是不可以随意触发的,必须要由用户的可信事件来触发。那么我们就只能通过这个事件来读取clipboardData中的值,这里需要关注的数据除了先前复制的key,还有files文件字段需要处理。对于反序列化,我们同样需要在插件中具体实现,同样是需要原地修改的Context定义。
  1. // packages/core/src/clipboard/utils/types.ts
  2. /** HTML => Fragment */
  3. export type PasteContext = {
  4.   /** Node 目标 */
  5.   nodes: BaseNode[];
  6.   /** HTML 基准 */
  7.   html: Node;
  8.   /** FILE 基准 */
  9.   files?: File[];
  10. };
  11. /** Clipboard => Context */
  12. export type PasteNodesContext = {
  13.   /** Node 基准 */
  14.   nodes: BaseNode[];
  15. };
  16. // packages/core/src/plugin/modules/declare.ts
  17. abstract class BasePlugin {
  18.   /** 将 HTML 反序列化为 Fragment  */
  19.   public deserialize?(context: PasteContext): void;
  20.   /** 粘贴的内容即将应用到编辑器 */
  21.   public willApplyPasteNodes?(context: PasteNodesContext): void;
  22. }
复制代码
这里的调度形式与序列化类似,如果剪贴板中存在application/x-doc-editor的key,则直接读取这个值。如果存在文件需要处理,则调度所有插件处理,否则则需要读取text/html的值,如果不存在的话就直接读取text/plain内容,同样构造JSON应用到编辑器中。
  1. // packages/core/src/clipboard/modules/paste.ts
  2. const files = Array.from(transfer.files);
  3. const textDoc = transfer.getData(TEXT_DOC);
  4. const textHTML = transfer.getData(TEXT_HTML);
  5. const textPlain = transfer.getData(TEXT_PLAIN);
  6. if (textDoc) {
  7.   // ...
  8. }
  9. if (files.length) {
  10.   // ...
  11. }
  12. if (textHTML) {
  13.   // ...
  14. }
  15. if (textPlain) {
  16.   // ...
  17. }
复制代码
这里的重点是对于text/html的处理,也就是反序列化将HTML节点转换为Fragment节点,这里的处理方式与序列化类似,同样是需要递归地处理数据。首先需要对HTML使用DOMParser对象进行解析,然后深度优先遍历由内而外处理每个节点,具体的实现依然需要调度插件来处理。
  1. // packages/core/src/clipboard/modules/paste.ts
  2. const parser = new DOMParser();
  3. const html = parser.parseFromString(textHTML, TEXT_HTML);
  4. // ...
  5. const root: BaseNode[] = [];
  6. // NOTE: 结束条件 `Text`、`Image`等节点都会在此时处理
  7. if (current.childNodes.length === 0) {
  8.   if (isDOMText(current)) {
  9.     const text = current.textContent || "";
  10.     root.push({ text });
  11.   } else {
  12.     const context: PasteContext = { nodes: root, html: current };
  13.     this.plugin.call(CALLER_TYPE.DESERIALIZE, context);
  14.     return context.nodes;
  15.   }
  16.   return root;
  17. }
  18. const children = Array.from(current.childNodes);
  19. for (const child of children) {
  20.   const nodes = this.deserialize(child);
  21.   nodes.length && root.push(...nodes);
  22. }
  23. const context: PasteContext = { nodes: root, html: current };
  24. this.plugin.call(CALLER_TYPE.DESERIALIZE, context);
  25. return context.nodes;
复制代码
接下来我们将会以slate为例,处理嵌套结构的剪贴板模块设计。并且以飞书文档的内容为源和目标,分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,在上述基本模式的调度下,分类型进行序列化与反序列化的插件实现。
行内结构

行内结构指的是加粗、斜体、下划线、删除线、行内代码块等行内的结构样式,这里以加粗为例来处理序列化与反序列化。在序列化行内结构部分,我们只需要判断如果是文本节点,就为其包裹一层strong节点,注意的是我们需要原地处理。
  1. // packages/plugin/src/bold/index.tsx
  2. export class BoldPlugin extends LeafPlugin {
  3.   public serialize(context: CopyContext) {
  4.     const { node, html } = context;
  5.     if (node[BOLD_KEY]) {
  6.       const strong = document.createElement("strong");
  7.       // NOTE: 采用`Wrap Base Node`加原地替换的方式
  8.       strong.appendChild(html);
  9.       context.html = strong;
  10.     }
  11.   }
  12. }
复制代码
反序列化这部分我们也需要前提处理,我们还需要先处理纯文本的内容,这是公共的处理方式,即所有节点都是文本节点时,我们需要加入一级行节点。并且还需要对数据进行格式化,理论上我们应该对所有的节点都过滤一次Normalize,但是这里就简单地处理空节点数据。
  1. // packages/plugin/src/clipboard/index.ts
  2. export class ClipboardPlugin extends BlockPlugin {
  3.   public deserialize(context: PasteContext): void {
  4.     const { nodes, html } = context;
  5.     if (nodes.every(isText) && isMatchBlockTag(html)) {
  6.       context.nodes = [{ children: nodes }];
  7.     }
  8.   }
  9.   public willApplyPasteNodes(context: PasteNodesContext): void {
  10.     const nodes = context.nodes;
  11.     const queue: BaseNode[] = [...nodes];
  12.     while (queue.length) {
  13.       const node = queue.shift();
  14.       if (!node) continue;
  15.       node.children && queue.push(...node.children);
  16.       // FIX: 兜底处理无文本节点的情况 例如
  17.       if (node.children && !node.children.length) {
  18.         node.children.push({ text: "" });
  19.       }
  20.     }
  21.   }
  22. }
复制代码
对于内容的处理则是判断出HTML节点存在加粗的格式后,对当前已经处理的Node节点树中所有的文本节点实现加粗操作,这里同样需要原地处理数据。这里我们还封装了applyMark的方法,用来处理所有的文本节点格式。其实这里有趣的是,因为我们的目标是构造整个JSON,我们就不需要关注使用slate的Transform模块操作Model。
  1. // packages/plugin/src/clipboard/utils/apply.ts
  2. export class BoldPlugin extends LeafPlugin {
  3.   public deserialize(context: PasteContext): void {
  4.     const { nodes, html } = context;
  5.     if (!isHTMLElement(html)) return void 0;
  6.     if (isMatchTag(html, "strong") || isMatchTag(html, "b") || html.style.fontWeight === "bold") {
  7.       // applyMarker packages/plugin/src/clipboard/utils/apply.ts
  8.       context.nodes = applyMarker(nodes, { [BOLD_KEY]: true });
  9.     }
  10.   }
  11. }
复制代码
段落结构

段落结构指的是标题、行高、文本对齐等结构样式,这里则以标题为例来处理序列化与反序列化。序列化段落结构,我们只需要Node是标题节点时,构造相关的HTML节点,将本来的节点原地包装并赋值到context即可,同样采用嵌套节点的方式。
  1. // packages/plugin/src/heading/index.tsx
  2. export class HeadingPlugin extends BlockPlugin {
  3.   public serialize(context: CopyContext): void {
  4.     const element = context.node as BlockElement;
  5.     const heading = element[HEADING_KEY];
  6.     if (!heading) return void 0;
  7.     const id = heading.id;
  8.     const type = heading.type;
  9.     const node = document.createElement(type);
  10.     node.id = id;
  11.     node.setAttribute("data-type", HEADING_KEY);
  12.     node.appendChild(context.html);
  13.     context.html = node;
  14.   }
  15. }
复制代码
反序列化则是相反的操作,判断当前正在处理的HTML节点是否为标题节点,如果是的话就将其转换为Node节点。这里同样需要原地处理数据,与行内节点不同的是,需要使用applyLineMarker将所有的行节点加入标题格式。
  1. // packages/plugin/src/heading/index.tsx
  2. export class HeadingPlugin extends BlockPlugin {
  3.   public deserialize(context: PasteContext): void {
  4.     const { nodes, html } = context;
  5.     if (!isHTMLElement(html)) return void 0;
  6.     const tagName = html.tagName.toLocaleLowerCase();
  7.     if (tagName.startsWith("h") && tagName.length === 2) {
  8.       let level = Number(tagName.replace("h", ""));
  9.       if (level <= 0 || level > 3) level = 3;
  10.       // applyLineMarker packages/plugin/src/clipboard/utils/apply.ts
  11.       context.nodes = applyLineMarker(this.editor, nodes, {
  12.         [HEADING_KEY]: { type: `h` + level, id: getId() },
  13.       });
  14.     }
  15.   }
  16. }
复制代码
组合结构

组合结构在这里指的是引用块、有序列表、无序列表等结构样式,这里则以引用块为例来处理序列化与反序列化。序列化组合结构,同样需要Node是引用块节点时,构造相关的HTML节点进行包装。
  1. // packages/plugin/src/quote-block/index.tsx
  2. export class QuoteBlockPlugin extends BlockPlugin {
  3.   public serialize(context: CopyContext): void {
  4.     const element = context.node as BlockElement;
  5.     const quote = element[QUOTE_BLOCK_KEY];
  6.     if (!quote) return void 0;
  7.     const node = document.createElement("blockquote");
  8.     node.setAttribute("data-type", QUOTE_BLOCK_KEY);
  9.     node.appendChild(context.html);
  10.     context.html = node;
  11.   }
  12. }
复制代码
反序列化同样是判断是否为引用块节点,并且构造对应的Node节点。这里与标题模块不同的是,标题是将格式应用到相关的行节点上,而引用块则是在原本的节点上嵌套一层结构。
  1. // packages/plugin/src/quote-block/index.tsx
  2. export class QuoteBlockPlugin extends BlockPlugin {
  3.   public deserialize(context: PasteContext): void {
  4.     const { nodes, html } = context;
  5.     if (!isHTMLElement(html)) return void 0;
  6.     if (isMatchTag(html, "blockquote")) {
  7.       const current = applyLineMarker(this.editor, nodes, {
  8.         [QUOTE_BLOCK_ITEM_KEY]: true,
  9.       });
  10.       context.nodes = [{ children: current, [QUOTE_BLOCK_KEY]: true }];
  11.     }
  12.   }
  13. }
复制代码
嵌入结构

嵌入结构在这里指的是图片、视频、流程图等结构样式,这里则以图片为例来处理序列化与反序列化。序列化嵌入结构,我们只需要Node是图片节点时,构造相关的HTML节点进行包装。与之前的节点不同的是,此时我们不需要嵌套DOM节点了,将独立节点原地替换即可。
  1. // packages/plugin/src/image/index.tsx
  2. export class ImagePlugin extends BlockPlugin {
  3.   public serialize(context: CopyContext): void {
  4.     const element = context.node as BlockElement;
  5.     const img = element[IMAGE_KEY];
  6.     if (!img) return void 0;
  7.     const node = document.createElement("img");
  8.     node.src = img.src;
  9.     node.setAttribute("data-type", IMAGE_KEY);
  10.     node.appendChild(context.html);
  11.     context.html = node;
  12.   }
  13. }
复制代码
对于反序列化的结构,判断当前正在处理的HTML节点是否为图片节点,如果是的话就将其转换为Node节点。与先前的转换不同的是,我们此时不需要嵌套结构,只需要固定children为零宽字符占位即可。实际上这里还有个常用的操作是,粘贴图片内容通常需要将原本的src转储到我们的服务上,例如飞书的图片就是临时链接,在生产环境中需要转储资源。
  1. // packages/plugin/src/image/index.tsx
  2. export class ImagePlugin extends BlockPlugin {
  3.   public deserialize(context: PasteContext): void {
  4.     const { html } = context;
  5.     if (!isHTMLElement(html)) return void 0;
  6.     if (isMatchTag(html, "img")) {
  7.       const src = html.getAttribute("src") || "";
  8.       const width = html.getAttribute("data-width") || 100;
  9.       const height = html.getAttribute("data-height") || 100;
  10.       context.nodes = [
  11.         {
  12.           [IMAGE_KEY]: {
  13.             src: src,
  14.             status: IMAGE_STATUS.SUCCESS,
  15.             width: Number(width),
  16.             height: Number(height),
  17.           },
  18.           uuid: getId(),
  19.           children: [{ text: "" }],
  20.         },
  21.       ];
  22.     }
  23.   }
  24. }
复制代码
块级结构

块级结构指的是高亮块、代码块、表格等结构样式,这里则以高亮块为例来处理序列化与反序列化。高亮块则是飞书中比较定制的结构,本质上是Editable结构的嵌套,这里的两层callout嵌套结构则是为了兼容飞书的结构。序列化块级结构在slate中跟引用结构类似,在外层直接嵌套组合结构即可。
  1. // packages/plugin/src/highlight-block/index.tsx
  2. export class HighlightBlockPlugin extends BlockPlugin {
  3.   public serialize(context: CopyContext): void {
  4.     const { node: node, html } = context;
  5.     if (this.reflex.isBlock(node) && node[HIGHLIGHT_BLOCK_KEY]) {
  6.       const colors = node[HIGHLIGHT_BLOCK_KEY]!;
  7.       // 提取具体色值
  8.       const border = colors.border || "";
  9.       const background = colors.background || "";
  10.       const regexp = /rgb\((.+)\)/;
  11.       const borderVar = RegExec.exec(regexp, border);
  12.       const backgroundVar = RegExec.exec(regexp, background);
  13.       const style = window.getComputedStyle(document.body);
  14.       const borderValue = style.getPropertyValue(borderVar);
  15.       const backgroundValue = style.getPropertyValue(backgroundVar);
  16.       // 构建 HTML 容器节点
  17.       const container = document.createElement("div");
  18.       container.setAttribute(HL_DOM_TAG, "true");
  19.       container.classList.add("callout-container");
  20.       container.style.border = `1px solid rgb(` + borderValue + `)`;
  21.       container.style.background = `rgb(` + backgroundValue + `)`;
  22.       container.setAttribute("data-emoji-id", "balloon");
  23.       const block = document.createElement("div");
  24.       block.classList.add("callout-block");
  25.       container.appendChild(block);
  26.       block.appendChild(html);
  27.       context.html = container;
  28.     }
  29.   }
  30. }
复制代码
反序列化则是判断当前正在处理的HTML节点是否为高亮块节点,如果是的话就将其转换为Node节点。这里的处理方式同样与引用块类似,只是需要在外层嵌套一层结构。
  1. // packages/plugin/src/highlight-block/index.tsx
  2. export class HighlightBlockPlugin extends BlockPlugin {
  3.   public deserialize(context: PasteContext): void {
  4.     const { nodes, html: node } = context;
  5.     if (isHTMLElement(node) && node.classList.contains("callout-block")) {
  6.       const border = node.style.borderColor;
  7.       const background = node.style.backgroundColor;
  8.       const regexp = /rgb\((.+)\)/;
  9.       const borderColor = border && RegExec.exec(regexp, border);
  10.       const backgroundColor = background && RegExec.exec(regexp, background);
  11.       if (!borderColor || !backgroundColor) return void 0;
  12.       context.nodes = [
  13.         {
  14.           [HIGHLIGHT_BLOCK_KEY]: {
  15.             border: borderColor,
  16.             background: backgroundColor,
  17.           },
  18.           children: nodes,
  19.         },
  20.       ];
  21.     }
  22.   }
  23. }
复制代码
扁平结构

quill的基本数据结构是扁平结构的JSON类型,相关的DEMO实现都在https://github.com/WindRunnerMax/BlockKit中。我们同样以标题与加粗的格式为例,描述其基础内容结构:
  1. [
  2.   { insert: "Editor" },
  3.   { attributes: { heading: "h1" }, insert: "\n" },
  4.   { attributes: { bold: "true" }, insert: "加粗" },
  5.   { insert: "格式" },
  6.   { insert: "\n" },
  7. ];
复制代码
序列化的调度方案与slate类似,我们同样需要在剪贴板模块提供基本的序列化与反序列化的接口,而具体的实现则归于插件本身处理。针对序列化的方法,也是按照基本行遍历的方式,优先处理Delta结构的的文本,再处理行结构的格式。但是由于delta的数据结构是扁平的,因此我们不能直接递归处理,而是应该循环到EOL时将当前行的节点更新为新的行节点。
  1. // packages/core/src/clipboard/modules/copy.ts
  2. const root = rootNode || document.createDocumentFragment();
  3. let lineFragment = document.createDocumentFragment();
  4. const ops = normalizeEOL(delta.ops);
  5. for (const op of ops) {
  6.   if (isEOLOp(op)) {
  7.     const context: SerializeContext = { op, html: lineFragment };
  8.     this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context);
  9.     let lineNode = context.html as HTMLElement;
  10.     if (!isMatchBlockTag(lineNode)) {
  11.       lineNode = document.createElement("div");
  12.       lineNode.setAttribute(LINE_TAG, "true");
  13.       lineNode.appendChild(context.html);
  14.     }
  15.     root.appendChild(lineNode);
  16.     lineFragment = document.createDocumentFragment();
  17.     continue;
  18.   }
  19.   const text = op.insert || "";
  20.   const textNode = document.createTextNode(text);
  21.   const context: SerializeContext = { op, html: textNode };
  22.   this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context);
  23.   lineFragment.appendChild(context.html);
  24. }
复制代码
反序列化的整体流程则与slate更加类似,因为我们同样都是以HTML为基准处理数据,深度递归遍历优先处理叶子节点,然后以处理过的delta为基准处理额外节点。只不过这里我们最终输出的数据结构会是扁平的,这样的话就不需要特别关注Normalize的操作。
  1. // packages/core/src/clipboard/modules/paste.ts
  2. public deserialize(current: Node): Delta {
  3.   const delta = new Delta();
  4.   // 结束条件 Text Image 等节点都会在此时处理
  5.   if (!current.childNodes.length) {
  6.     if (isDOMText(current)) {
  7.       const text = current.textContent || "";
  8.       delta.insert(text);
  9.     } else {
  10.       const context: DeserializeContext = { delta, html: current };
  11.       this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context);
  12.       return context.delta;
  13.     }
  14.     return delta;
  15.   }
  16.   const children = Array.from(current.childNodes);
  17.   for (const child of children) {
  18.     const newDelta = this.deserialize(child);
  19.     delta.ops.push(...newDelta.ops);
  20.   }
  21.   const context: DeserializeContext = { delta, html: current };
  22.   this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context);
  23.   return context.delta;
  24. }
复制代码
此外,对于块级嵌套结构的处理,我们的处理方式可能会更加复杂,但是在当前的实现中还并没有完成,因此暂时还处于设计阶段。序列化的处理方式类似于下面的流程,与先前结构不同的是,当处理到块结构时,直接调用剪贴板的序列化模块,将内容嵌入即可。
  1.                               | --  bold ··· <strong> -- |
  2.                  | -- line -- |                          | --  ---|
  3.                  |            | --  text ···  ---- |             |
  4.                  |                                                     |
  5. root -- lines -- | -- line -- leaves ··· <elements> ---------  ---| -- normalize -- html
  6.                  |                                                     |
  7.                  | -- codeblock -- ref(id) ···  -------  ---|
  8.                  |                                                     |
  9.                  | -- table -- ref(id) ··· <table> ----------  ---|
复制代码
反序列化的方式相对更复杂一些,因为我们需要维护嵌套结构的引用关系。虽然本身经过DOMParser解析过后的HTML是嵌套的内容,但是我们的基准解析方法目标是扁平的Delta结构,然而block、table等结构的形式是需要嵌套引用的结构,这个id的关系就需要我们以约定的形式完成。
  1.                                   | -- <b> -- text ··· text|r -- bold|r -- |
  2.           | --  -- <h1> -- |                                        | -- head|r -- align|r -- |
  3.           |                       | --  -- text ··· text|r -- link|r -- |                         |
  4. <body> -- |                                                                                          | -- deltas
  5.           |                       | -- <u> -- text ··· text|r -- unl|r --- |                         |
  6.           | --  --  -- |                                        | -- block|id -- ref|r -- |
  7.                                   | -- <i> -- text ··· text|r -- em|r ---- |
复制代码
接下来我们将会以delta数据结构为例,处理扁平结构的剪贴板模块设计。同样分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,在上述基本模式的调度下,分类型进行序列化与反序列化的插件实现。
行内结构

行内结构指的是加粗、斜体、下划线、删除线、行内代码块等行内的结构样式,这里以加粗为例来处理序列化与反序列化。序列化行内结构部分基本与slate一致,从这里开始我们采用单元测试的方式执行。
  1. // packages/core/test/clipboard/bold.test.ts
  2. it("serialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     serialize(context) {
  5.       if (context.op.attributes?.bold) {
  6.         const strong = document.createElement("strong");
  7.         strong.appendChild(context.html);
  8.         context.html = strong;
  9.       }
  10.     },
  11.   });
  12.   editor.plugin.register(plugin);
  13.   const delta = new Delta().insert("Hello", { bold: "true" }).insert("World");
  14.   const root = editor.clipboard.copyModule.serialize(delta);
  15.   const plainText = getFragmentText(root);
  16.   const htmlText = serializeHTML(root);
  17.   expect(plainText).toBe("HelloWorld");
  18.   expect(htmlText).toBe(`<strong>Hello</strong>World`);
  19. });
复制代码
反序列化部分则是判断当前正在处理的HTML节点是否为加粗节点,如果是的话就将其转换为Delta节点。
  1. // packages/core/test/clipboard/bold.test.ts
  2. it("deserialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     deserialize(context) {
  5.       const { delta, html } = context;
  6.       if (!isHTMLElement(html)) return void 0;
  7.       if (isMatchHTMLTag(html, "strong") || isMatchHTMLTag(html, "b") || html.style.fontWeight === "bold") {
  8.         // applyMarker packages/core/src/clipboard/utils/deserialize.ts
  9.         applyMarker(delta, { bold: "true" });
  10.       }
  11.     },
  12.   });
  13.   editor.plugin.register(plugin);
  14.   const parser = new DOMParser();
  15.   const transferHTMLText = `<strong>Hello</strong>World`;
  16.   const html = parser.parseFromString(transferHTMLText, "text/html");
  17.   const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
  18.   const delta = new Delta().insert("Hello", { bold: "true" }).insert("World");
  19.   expect(rootDelta).toEqual(delta);
  20. });
复制代码
段落结构

段落结构指的是标题、行高、文本对齐等结构样式,这里则以标题为例来处理序列化与反序列化。序列化段落结构,我们只需要Node是标题节点时,构造相关的HTML节点,将本来的节点原地包装并赋值到context即可,同样采用嵌套节点的方式。
  1. // packages/core/test/clipboard/heading.test.ts
  2. it("serialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     serialize(context) {
  5.       const { op, html } = context;
  6.       if (isEOLOp(op) && op.attributes?.heading) {
  7.         const element = document.createElement(op.attributes.heading);
  8.         element.appendChild(html);
  9.         context.html = element;
  10.       }
  11.     },
  12.   });
  13.   editor.plugin.register(plugin);
  14.   const delta = new MutateDelta().insert("Hello").insert("\n", { heading: "h1" });
  15.   const root = editor.clipboard.copyModule.serialize(delta);
  16.   const plainText = getFragmentText(root);
  17.   const htmlText = serializeHTML(root);
  18.   expect(plainText).toBe("Hello");
  19.   expect(htmlText).toBe(`<h1>Hello</h1>`);
  20. });
复制代码
反序列化则是相反的操作,判断当前正在处理的HTML节点是否为标题节点,如果是的话就将其转换为Node节点。这里同样需要原地处理数据,与行内节点不同的是,需要使用applyLineMarker将所有的行节点加入标题格式。
  1. // packages/core/test/clipboard/heading.test.ts
  2. it("deserialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     deserialize(context) {
  5.       const { delta, html } = context;
  6.       if (!isHTMLElement(html)) return void 0;
  7.       if (["h1", "h2"].indexOf(html.tagName.toLowerCase()) > -1) {
  8.         applyLineMarker(delta, { heading: html.tagName.toLowerCase() });
  9.       }
  10.     },
  11.   });
  12.   editor.plugin.register(plugin);
  13.   const parser = new DOMParser();
  14.   const transferHTMLText = `<h1>Hello</h1><h2>World</h2>`;
  15.   const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
  16.   const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
  17.   const delta = new Delta()
  18.     .insert("Hello")
  19.     .insert("\n", { heading: "h1" })
  20.     .insert("World")
  21.     .insert("\n", { heading: "h2" });
  22.   expect(rootDelta).toEqual(MutateDelta.from(delta));
  23. });
复制代码
组合结构

组合结构在这里指的是引用块、有序列表、无序列表等结构样式,这里则以引用块为例来处理序列化与反序列化。序列化组合结构,我同样需要Node是引用块节点时,构造相关的HTML节点进行包装。在扁平结构下类似组合结构的处理方式会是渲染时进行的,因此序列化的过程与先前标题一致。
  1. // packages/core/test/clipboard/quote.test.ts
  2. it("serialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     serialize(context) {
  5.       const { op, html } = context;
  6.       if (isEOLOp(op) && op.attributes?.quote) {
  7.         const element = document.createElement("blockquote");
  8.         element.appendChild(html);
  9.         context.html = element;
  10.       }
  11.     },
  12.   });
  13.   editor.plugin.register(plugin);
  14.   const delta = new MutateDelta().insert("Hello").insert("\n", { quote: "true" });
  15.   const root = editor.clipboard.copyModule.serialize(delta);
  16.   const plainText = getFragmentText(root);
  17.   const htmlText = serializeHTML(root);
  18.   expect(plainText).toBe("Hello");
  19.   expect(htmlText).toBe(`<blockquote>Hello</blockquote>`);
  20. });
复制代码
反序列化同样是判断是否为引用块节点,并且构造对应的Node节点。这里与标题模块不同的是,标题是将格式应用到相关的行节点上,而引用块则是在原本的节点上嵌套一层结构。反序列化的结构处理方式也类似于标题处理方式,由于在HTML的结构上是嵌套结构,在应用时在所有行节点上加入引用格式。
  1. // packages/core/test/clipboard/quote.test.ts
  2. it("deserialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     deserialize(context) {
  5.       const { delta, html } = context;
  6.       if (!isHTMLElement(html)) return void 0;
  7.       if (isMatchHTMLTag(html, "p")) {
  8.         applyLineMarker(delta, {});
  9.       }
  10.       if (isMatchHTMLTag(html, "blockquote")) {
  11.         applyLineMarker(delta, { quote: "true" });
  12.       }
  13.     },
  14.   });
  15.   editor.plugin.register(plugin);
  16.   const parser = new DOMParser();
  17.   const transferHTMLText = `<blockquote><p>Hello</p><p>World</p></blockquote>`;
  18.   const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
  19.   const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
  20.   const delta = new Delta()
  21.     .insert("Hello")
  22.     .insert("\n", { quote: "true" })
  23.     .insert("World")
  24.     .insert("\n", { quote: "true" });
  25.   expect(rootDelta).toEqual(MutateDelta.from(delta));
  26. });
复制代码
嵌入结构

嵌入结构在这里指的是图片、视频、流程图等结构样式,这里则以图片为例来处理序列化与反序列化。序列化嵌入结构,我们只需要Node是图片节点时,构造相关的HTML节点进行包装。与之前的节点不同的是,此时我们不需要嵌套DOM节点了,将独立节点原地替换即可。
  1. // packages/core/test/clipboard/image.test.ts
  2. it("serialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     serialize(context) {
  5.       const { op } = context;
  6.       if (op.attributes?.image && op.attributes.src) {
  7.         const element = document.createElement("img");
  8.         element.src = op.attributes.src;
  9.         context.html = element;
  10.       }
  11.     },
  12.   });
  13.   editor.plugin.register(plugin);
  14.   const delta = new Delta().insert(" ", {
  15.     image: "true",
  16.     src: "https://example.com/image.png",
  17.   });
  18.   const root = editor.clipboard.copyModule.serialize(delta);
  19.   const plainText = getFragmentText(root);
  20.   const htmlText = serializeHTML(root);
  21.   expect(plainText).toBe("");
  22.   expect(htmlText).toBe(`<img src="https://example.com/image.png">`);
  23. });
复制代码
对于反序列化的结构,判断当前正在处理的HTML节点是否为图片节点,如果是的话就将其转换为Node节点。同样的,这里还有个常用的操作是,粘贴图片内容通常需要将原本的src转储到我们的服务上,例如飞书的图片就是临时链接,在生产环境中需要转储资源。
  1. // packages/core/test/clipboard/image.test.ts
  2. it("deserialize", () => {
  3.   const plugin = getMockedPlugin({
  4.     deserialize(context) {
  5.       const { html } = context;
  6.       if (!isHTMLElement(html)) return void 0;
  7.       if (isMatchHTMLTag(html, "img")) {
  8.         const src = html.getAttribute("src") || "";
  9.         const delta = new Delta();
  10.         delta.insert(" ", { image: "true", src: src });
  11.         context.delta = delta;
  12.       }
  13.     },
  14.   });
  15.   editor.plugin.register(plugin);
  16.   const parser = new DOMParser();
  17.   const transferHTMLText = `<img src="https://example.com/image.png"></img>`;
  18.   const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
  19.   const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
  20.   const delta = new Delta().insert(" ", { image: "true", src: "https://example.com/image.png" });
  21.   expect(rootDelta).toEqual(delta);
  22. });
复制代码
块级结构

块级结构

块级结构指的是高亮块、代码块、表格等结构样式,这里则以块结构为例来处理序列化与反序列化。这里的嵌套结构还没有实现,因此这里仅仅是实现了上述deltas图示的测试用例,主要的处理方式是当存在引用关系时,主动调用序列化的方式将其写入到HTML中。
  1. it("serialize", () => {
  2.   const block = new Delta().insert("inside");
  3.   const inside = editor.clipboard.copyModule.serialize(block);
  4.   const plugin = getMockedPlugin({
  5.     serialize(context) {
  6.       const { op } = context;
  7.       if (op.attributes?._ref) {
  8.         const element = document.createElement("div");
  9.         element.setAttribute("data-block", op.attributes._ref);
  10.         element.appendChild(inside);
  11.         context.html = element;
  12.       }
  13.     },
  14.   });
  15.   editor.plugin.register(plugin);
  16.   const delta = new Delta().insert(" ", { _ref: "id" });
  17.   const root = editor.clipboard.copyModule.serialize(delta);
  18.   const plainText = getFragmentText(root);
  19.   const htmlText = serializeHTML(root);
  20.   expect(plainText).toBe("inside\n");
  21.   expect(htmlText).toBe(
  22.     `inside`
  23.   );
  24. });
复制代码
反序列化则是判断当前正在处理的HTML节点是否为块级节点,如果是的话就将其转换为Node节点。这里的处理方式则是,深度优先遍历处理节点内容时,若是出现block节点,则生成id并放置于deltas中,然后在ROOT结构中引用该节点。
  1. it("deserialize", () => {
  2.   const deltas: Record<string, Delta> = {};
  3.   const plugin = getMockedPlugin({
  4.     deserialize(context) {
  5.       const { html } = context;
  6.       if (!isHTMLElement(html)) return void 0;
  7.       if (isMatchHTMLTag(html, "div") && html.hasAttribute("data-block")) {
  8.         const id = html.getAttribute("data-block")!;
  9.         deltas[id] = context.delta;
  10.         context.delta = new Delta().insert(" ", { _ref: id });
  11.       }
  12.     },
  13.   });
  14.   editor.plugin.register(plugin);
  15.   const parser = new DOMParser();
  16.   const transferHTMLText = `inside`;
  17.   const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
  18.   const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
  19.   deltas[ROOT_BLOCK] = rootDelta;
  20.   expect(deltas).toEqual({
  21.     [ROOT_BLOCK]: new Delta().insert(" ", { _ref: "id" }),
  22.     id: new Delta().insert("inside"),
  23.   });
  24. });
复制代码
每日一题


  • https://github.com/WindRunnerMax/EveryDay
参考


  • https://quilljs.com/docs/modules/clipboard
  • https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API
  • https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
  • https://github.com/slab/quill/blob/ebe16ca/packages/quill/src/modules/clipboard.ts
  • https://github.com/ianstormtaylor/slate/blob/dbd0a3e/packages/slate-dom/src/utils/dom.ts
  • https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册