# 初探富文本之序列化与反序列化 在富文本编辑器中,序列化与反序列化是非常重要的环节,其涉及到了编辑器的内容复制、粘贴、导入导出等模块。当用户在编辑器中进行复制操作时,富文本内容会被转换为标准的`HTML`格式,并存储在剪贴板中。而在粘贴操作中,编辑器则需要将这些`HTML`内容解析并转换为编辑器的私有`JSON`结构,以便于实现跨编辑器内容的统一管理。
初探富文本编辑器系列相关文章 * [初探富文本编辑器#1-富文本概述](./初探富文本之富文本概述.md) * [初探富文本编辑器#2-编辑器引擎](./初探富文本之编辑器引擎.md) * [初探富文本编辑器#3-OT 协同算法](./初探富文本之OT协同算法.md) * [初探富文本编辑器#4-OT 协同实例](./初探富文本之OT协同实例.md) * [初探富文本编辑器#5-CRDT 协同算法](./初探富文本之CRDT协同算法.md) * [初探富文本编辑器#6-CRDT 协同实例](./初探富文本之CRDT协同实例.md) * [初探富文本编辑器#7-React 实时预览](./初探富文本之React实时预览.md) * [初探富文本编辑器#8-文档 diff 算法](./初探富文本之文档diff算法.md) * [初探富文本编辑器#9-在线文档交付](./初探富文本之在线文档交付.md) * [初探富文本编辑器#10-划词评论能力](./初探富文本之划词评论能力.md) * [初探富文本编辑器#11-文档虚拟滚动](./初探富文本之文档虚拟滚动.md) * [初探富文本编辑器#12-搜索替换算法](./初探富文本之搜索替换算法.md) * [初探富文本编辑器#13-序列化与反序列化](./初探富文本之序列化与反序列化.md)
## 概述 我们平时在使用一些在线文档编辑器的时候,可能会好奇一个问题,为什么我们能够直接把格式复制出来,而不仅仅是纯文本,甚至于说从浏览器中复制内容到`Office Word`都可以保留格式。这看起来是不是一件很神奇的事情,不过当我们了解到剪贴板的基本操作之后,就可以了解这其中的底层实现了。 说到剪贴板的操作,在执行复制行为的时候,我们可能会认为复制的就是纯文本,然而显然光靠复制纯文本我们是做不到上述的功能。所以实际上剪贴板是可以存储复杂内容的,那么在这里我们以`Word`为例,当我们从`Word`中复制文本时,其实际上是会在剪贴板中写入这么几个`key`值: ```text text/plain text/html text/rtf image/png ``` 看着`text/plain`是不是很眼熟,这显然就是我们常见的`Content-Type`或者称作`MIME-Type`,所以说我们是不是可以认为剪贴板是一个`Record`的结构类型。但是别忽略了我们还有一个`image/png`类型,因为我们的剪贴板是可以直接复制文件的,所以我们常用的剪贴板类型就是`Record`,**例如此时复制这段文字***在剪贴板中就是如下内容*。 ```text text/plain 例如此时复制这段文字在剪贴板中就是如下内容 text/html 例如此时复制这段文字在剪贴板中就是如下内容 ``` 那么我们执行粘贴操作的时候就很明显了,只需要从剪贴板里读取内容就可以。例如我们从语雀复制内容到飞书中时,在语雀复制的时候会将`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`。 ```js document.execCommand("selectAll"); const res = document.execCommand("copy"); console.log(res); // true ``` ```js const dataItems: Record = {}; for (const [key, value] of Object.entries(data)) { const blob = new Blob([value], { type: key }); dataItems[key] = blob; } navigator.clipboard.write([new ClipboardItem(dataItems)]) ``` 而对于序列化即粘贴行为,则存在`document.execCommand("paste")`以及`navigator.clipboard.read/readText`可用。但是需要注意的是`execCommand`这个`API`的调用总是会失败,`clipboard.read`则需要用户主动授权。关于这个问题,我们在先前通过浏览器扩展对可信事件的研究也已经有过结论,在扩展中即使保持清单中的`clipboardRead`权限声明,也无法直接读取剪贴板,必须要在`Content Script`甚至`chrome.debugger`中才可以执行。 ```js document.addEventListener("paste", (e) => { const data = e.clipboardData; console.log(data); }); const res = document.execCommand("paste"); console.log(res); // false ``` ```js navigator.clipboard.read().then(res => { for (const item of res) { item.getType("text/html").then(console.log).catch(() => null) } }); ``` 当然这里并不是此时研究的重点,我们关注的是内容的序列化与反序列化,即在富文本编辑器的复制粘贴模块的设计。当然这个模块还会有更广泛的用途,例如序列化的场景有交付`Word`文档、输出`Markdown`格式等,反序列的场景有导入`Markdown`文档等。而我们对于这个模块的设计,则需要考虑到以下几个问题: 1. 插件化,编辑器中的模块本身都是插件化的,那么关于剪贴板模块的设计自然也需要能够自由扩展序列化/反序列化的格式。特别是在需要精确适配编辑器例如飞书、语雀等的私有格式时,需要能够自由控制相关行为。 2. 普适性,由于富文本需要实现`DOM`与选区`MODEL`的映射,因此生成的`DOM`结构通常会比较复杂。而当我们从文档中复制内容到剪贴板时,我们会希望这个结构是更规范化的,以便粘贴到其他平台例如飞书、`Word`等时会有更好的解析。 3. 完整性,当执行序列化与反序列时,希望能够保持内容的完整性,即不会因为这个的过程而丢失内容,这里相当于对性能做出让步而保持内容完整。而对于编辑器本身的格式则关注性能,由于实际注册的模块一致,希望能够直接应用数据而不需要走整个解析过程。 那么本文将会以`slate`为例,处理嵌套结构的剪贴板模块设计,并且以`quill`为例,处理扁平结构的剪贴板模块设计。并且以飞书文档的内容为例,分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,分类型进行序列化与反序列化的设计。 ## 嵌套数据类型 `slate`的基本数据结构是树形结构的`JSON`类型,相关的`DEMO`实现都在`https://github.com/WindRunnerMax/DocEditor`中。我们先以标题与加粗的格式为例,描述其基础内容结构: ```js [ { children: [{ text: "Editor" }], heading: { type: "h1", id: "W5xjbuxy" } }, { children: [{ text: "加粗", bold: true }, { text: "格式" }] }, ]; ``` 实际上`slate`的数据结构形式非常类似于`DOM`结构的嵌套格式,甚至于`DOM`结构与数据结构是完全一一对应的,例如在渲染`Embed`结构中的零宽字符渲染时也会在数据结构中存在。因此在实现序列化与反序列化的过程中,理论上我们是可以直接实现其`JSON`结构完全对应为`DOM`结构的转换。 然而完全对应的情况只是理想情况下,富文本编辑器对于内容的实际组织形式可能会多种多样,例如实现引用块结构时,外层包裹的`blockquote`标签可能是数据结构本身存在,也可能是渲染时根据行属性动态渲染的,这种情况下就不能直接从数据结构的层面上将其序列化为完整的`HTML`。 ```js // 结构渲染 [ { blockquote: true, children:[ { children: [{ text: "引用块 Line 1" }] }, { children: [{ text: "引用块 Line 2" }] }, ] } ]; // 动态渲染 [ { children: [{ text: "引用块 Line 1" }], blockquote: true }, { children: [{ text: "引用块 Line 2" }], blockquote: true }, ]; ``` 此外,我们实现的编辑器必然是需要插件化的,在剪贴板模块中我们无法准确得知插件究竟是如何组织数据结构的。而在富文本编辑器中有着不成文的规矩,我们写入剪贴板的内容需要是尽可能规范化的结构,否则就无法跨编辑器粘贴内容。因此我们如果希望能够保证规范化的数据,就需要在剪贴板模块提供基本的序列化与反序列化的接口,而具体的实现则归于插件本身处理。 那么基于这个基本理念,我们首先来看序列化的实现,即`JSON`结构到`HTML`的转换过程。先前我们也提到了,对于编辑器本身的格式则关注性能,由于实际注册的模块一致,希望能够直接应用数据而不需要走整个解析过程,因此我们还需要在剪贴板中额外写入`application/x-doc-editor`的`key`,用来直接存储`Fragment`数据。 ```js { "text/plain": "Editor\n加粗格式", "text/html": "

Editor

加粗格式
", "application/x-doc-editor": '[{"children":[{"text":"Editor"}],"heading":{"type":"h1","id":"W5xjbuxy"}},{"children":[{"text":"加粗","bold":true},{"text":"格式"}]}]', } ``` 我们接下来需要设想下如何将内容写入到剪贴板,以及实际触发的场景。除了常见的`Ctrl+C`来触发复制行为外,用户还有可能希望通过按钮来触发复制行为,例如飞书就可以通过工具栏复制整个行/块结构,因此我们不能直接通过`OnCopy`事件的`clipboardData`来写数据,而是需要主动触发额外的`Copy`事件。 前边也提到了`navigator.clipboard.write`同样可以写入剪贴板,调用这个`API`是不需要真正触发`Copy`事件的,但是当我们使用这个方法写入数据的时候,可能会抛出异常。此外这个`API`必须要在`HTTPS`环境下才能使用,否则会完全没有这个函数的定义。 在下面的例子中需要焦点在`document`上,需要在延迟时间内点击页面,否则会抛出`DOMException`。而即使当我们焦点在页面上,执行后同样会抛出`DOMException`,从抛出的异常来看是因为`application/x-doc-editor`类型不被支持。 ```js (async () => { await new Promise((resolve) => setTimeout(resolve, 3000)); const params = { "text/plain": "Editor", "text/html": "Editor", "application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]', } const dataItems = {}; for (const [key, value] of Object.entries(params)) { const blob = new Blob([value], { type: key }); dataItems[key] = blob; } // DOMException: Type application/x-doc-editor not supported on write. navigator.clipboard.write([new ClipboardItem(dataItems)]); })(); ``` 因为这个`API`不支持我们写入自定义的类型,因此我们就需要主动触发`Copy`事件来写入剪贴板,虽然我们同样可以将这个字段的数据作为`HTML`的某个属性值写入`text/html`中,但是我们这里还是将其独立出来处理。那么以同样的数据,我们使用`document.execCommand`写入剪贴板的方式就需要新建`textarea`元素来实现。 ```js const data = { "text/plain": "Editor", "text/html": "Editor", "application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]', } const textarea = document.createElement("textarea"); textarea.addEventListener("copy", event => { for (const [key, value] of Object.entries(data)) { event.clipboardData && event.clipboardData.setData(key, value); } event.stopPropagation(); event.preventDefault(); }); textarea.style.position = "fixed"; textarea.style.left = "-999px"; textarea.style.top = "-999px"; textarea.value = data["text/plain"]; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); ``` 当然这里我们能够很明显地看到由于`textarea.select`,我们原本的编辑器焦点会丢失。因此这里我们还需要注意,在执行复制的时候需要记录当前的选区值,在写入剪贴板之后先将焦点置于编辑器,之后再恢复选区。 接下来我们来处理插件化的定义,这里的`Context`非常简单,只需要记录当前正在处理的`Node`以及当前已经处理过后的`html`节点即可。而在插件中我们需要实现`serialize`方法,用来将`Node`序列化为`HTML`,`willSetToClipboard`则是`Hook`定义,当即将写入剪贴板时会被调用。 ```js // packages/core/src/clipboard/utils/types.ts /** Fragment => HTML */ export type CopyContext = { /** Node 基准 */ node: BaseNode; /** HTML 目标 */ html: Node; }; // packages/core/src/plugin/modules/declare.ts abstract class BasePlugin { /** 将 Fragment 序列化为 HTML */ public serialize?(context: CopyContext): void; /** 内容即将写入剪贴板 */ public willSetToClipboard?(context: CopyContext): void; } ``` 既然我们的具体转换是在插件中实现的,那么我们主要的工作就是调度插件的执行了。为了方便处理数据,我们这里就不使用`Immutable`的形式来处理了,我们的`Context`对象是整个调度过程中保持一致的,即插件中我们所有的方法都是原地处理的。那么调度的方式就直接通过`plugin`组件调度,调用后从`context`中获取`html`节点即可。 ```js // packages/core/src/plugin/modules/declare.ts public call(key: T, payload: CallerMap[T], type?: PluginType) { const plugins = this.current; for (const plugin of plugins) { try { // @ts-expect-error payload match plugin[key] && isFunction(plugin[key]) && plugin[key](payload); } catch (error) { this.editor.logger.warning(`Plugin Exec Error`, plugin, error); } } return payload; } const context: CopyContext = { node: child, html: textNode }; this.plugin.call(CALLER_TYPE.SERIALIZE, context); value.appendChild(context.html); ``` 那么重点的地方就是我们设计的`serialize`调度方法,我们这里的核心思想是: 当处理到文本行时,我们创建一个空的`Fragment`节点作为行节点,然后迭代每个文本值,取出当前行的每个`Text`值创建文本节点,以此创建`context`对象,然后调度`PLUGIN_TYPE.INLINE`级别的插件,将序列化后的`HTML`节点插入到行节点中。 ```js // packages/core/src/clipboard/modules/copy.ts if (this.reflex.isTextBlock(current)) { const lineFragment = document.createDocumentFragment(); current.children.forEach(child => { const text = child.text || ""; const textNode = document.createTextNode(text); const context: CopyContext = { node: child, html: textNode }; this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.INLINE); lineFragment.appendChild(context.html); }); } ``` 然后针对每个行节点,我们同样需要调度`PLUGIN_TYPE.BLOCK`级别的插件,将处理过后的内容放置于`root`节点中,并将内容返回。这样我们就完成了最基本的文本行的序列化操作,这里我们在`DOM`节点上加入了额外的标识,这样可以帮助我们在反序列化的时候能够幂等地处理。 ```js // packages/core/src/clipboard/modules/copy.ts const root = rootNode || document.createDocumentFragment(); // ... const context: CopyContext = { node: current, html: lineFragment }; this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK); const lineNode = document.createElement("div"); lineNode.setAttribute(LINE_TAG, "true"); lineNode.appendChild(context.html); root.appendChild(lineNode); ``` 在基本的行结构处理完成后,还需要关注外层的`Node`节点,这里的数据处理方式与行节点类似。但是这里需要注意的是,这里是递归的结构处理,那么这里的`JSON`结构执行顺序就是深度优先遍历,即先处理文本节点以及行节点,然后再处理外部的块结构,由内而外地处理,由此来保证整个`DOM`树形结构的处理。 ```js // packages/core/src/clipboard/modules/copy.ts if (this.reflex.isBlock(current)) { const blockFragment = document.createDocumentFragment(); current.children.forEach(child => this.serialize(child, blockFragment)); const context: CopyContext = { node: current, html: blockFragment }; this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK); root.appendChild(context.html); return root as T; } ``` 而对反序列化的处理则相对简单,`Paste`事件是不可以随意触发的,必须要由用户的可信事件来触发。那么我们就只能通过这个事件来读取`clipboardData`中的值,这里需要关注的数据除了先前复制的`key`,还有`files`文件字段需要处理。对于反序列化,我们同样需要在插件中具体实现,同样是需要原地修改的`Context`定义。 ```js // packages/core/src/clipboard/utils/types.ts /** HTML => Fragment */ export type PasteContext = { /** Node 目标 */ nodes: BaseNode[]; /** HTML 基准 */ html: Node; /** FILE 基准 */ files?: File[]; }; /** Clipboard => Context */ export type PasteNodesContext = { /** Node 基准 */ nodes: BaseNode[]; }; // packages/core/src/plugin/modules/declare.ts abstract class BasePlugin { /** 将 HTML 反序列化为 Fragment */ public deserialize?(context: PasteContext): void; /** 粘贴的内容即将应用到编辑器 */ public willApplyPasteNodes?(context: PasteNodesContext): void; } ``` 这里的调度形式与序列化类似,如果剪贴板中存在`application/x-doc-editor`的`key`,则直接读取这个值。如果存在文件需要处理,则调度所有插件处理,否则则需要读取`text/html`的值,如果不存在的话就直接读取`text/plain`内容,同样构造`JSON`应用到编辑器中。 ```js // packages/core/src/clipboard/modules/paste.ts const files = Array.from(transfer.files); const textDoc = transfer.getData(TEXT_DOC); const textHTML = transfer.getData(TEXT_HTML); const textPlain = transfer.getData(TEXT_PLAIN); if (textDoc) { // ... } if (files.length) { // ... } if (textHTML) { // ... } if (textPlain) { // ... } ``` 这里的重点是对于`text/html`的处理,也就是反序列化将`HTML`节点转换为`Fragment`节点,这里的处理方式与序列化类似,同样是需要递归地处理数据。首先需要对`HTML`使用`DOMParser`对象进行解析,然后深度优先遍历由内而外处理每个节点,具体的实现依然需要调度插件来处理。 ```js // packages/core/src/clipboard/modules/paste.ts const parser = new DOMParser(); const html = parser.parseFromString(textHTML, TEXT_HTML); // ... const root: BaseNode[] = []; // NOTE: 结束条件 `Text`、`Image`等节点都会在此时处理 if (current.childNodes.length === 0) { if (isDOMText(current)) { const text = current.textContent || ""; root.push({ text }); } else { const context: PasteContext = { nodes: root, html: current }; this.plugin.call(CALLER_TYPE.DESERIALIZE, context); return context.nodes; } return root; } const children = Array.from(current.childNodes); for (const child of children) { const nodes = this.deserialize(child); nodes.length && root.push(...nodes); } const context: PasteContext = { nodes: root, html: current }; this.plugin.call(CALLER_TYPE.DESERIALIZE, context); return context.nodes; ``` 接下来我们将会以`slate`为例,处理嵌套结构的剪贴板模块设计。并且以飞书文档的内容为源和目标,分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,在上述基本模式的调度下,分类型进行序列化与反序列化的插件实现。 ### 行内结构 行内结构指的是加粗、斜体、下划线、删除线、行内代码块等行内的结构样式,这里以加粗为例来处理序列化与反序列化。在序列化行内结构部分,我们只需要判断如果是文本节点,就为其包裹一层`strong`节点,注意的是我们需要原地处理。 ```js // packages/plugin/src/bold/index.tsx export class BoldPlugin extends LeafPlugin { public serialize(context: CopyContext) { const { node, html } = context; if (node[BOLD_KEY]) { const strong = document.createElement("strong"); // NOTE: 采用`Wrap Base Node`加原地替换的方式 strong.appendChild(html); context.html = strong; } } } ``` 反序列化这部分我们也需要前提处理,我们还需要先处理纯文本的内容,这是公共的处理方式,即所有节点都是文本节点时,我们需要加入一级行节点。并且还需要对数据进行格式化,理论上我们应该对所有的节点都过滤一次`Normalize`,但是这里就简单地处理空节点数据。 ```js // packages/plugin/src/clipboard/index.ts export class ClipboardPlugin extends BlockPlugin { public deserialize(context: PasteContext): void { const { nodes, html } = context; if (nodes.every(isText) && isMatchBlockTag(html)) { context.nodes = [{ children: nodes }]; } } public willApplyPasteNodes(context: PasteNodesContext): void { const nodes = context.nodes; const queue: BaseNode[] = [...nodes]; while (queue.length) { const node = queue.shift(); if (!node) continue; node.children && queue.push(...node.children); // FIX: 兜底处理无文本节点的情况 例如
if (node.children && !node.children.length) { node.children.push({ text: "" }); } } } } ``` 对于内容的处理则是判断出`HTML`节点存在加粗的格式后,对当前已经处理的`Node`节点树中所有的文本节点实现加粗操作,这里同样需要原地处理数据。这里我们还封装了`applyMark`的方法,用来处理所有的文本节点格式。其实这里有趣的是,因为我们的目标是构造整个`JSON`,我们就不需要关注使用`slate`的`Transform`模块操作`Model`。 ```js // packages/plugin/src/clipboard/utils/apply.ts export class BoldPlugin extends LeafPlugin { public deserialize(context: PasteContext): void { const { nodes, html } = context; if (!isHTMLElement(html)) return void 0; if (isMatchTag(html, "strong") || isMatchTag(html, "b") || html.style.fontWeight === "bold") { // applyMarker packages/plugin/src/clipboard/utils/apply.ts context.nodes = applyMarker(nodes, { [BOLD_KEY]: true }); } } } ``` ### 段落结构 段落结构指的是标题、行高、文本对齐等结构样式,这里则以标题为例来处理序列化与反序列化。序列化段落结构,我们只需要`Node`是标题节点时,构造相关的`HTML`节点,将本来的节点原地包装并赋值到`context`即可,同样采用嵌套节点的方式。 ```js // packages/plugin/src/heading/index.tsx export class HeadingPlugin extends BlockPlugin { public serialize(context: CopyContext): void { const element = context.node as BlockElement; const heading = element[HEADING_KEY]; if (!heading) return void 0; const id = heading.id; const type = heading.type; const node = document.createElement(type); node.id = id; node.setAttribute("data-type", HEADING_KEY); node.appendChild(context.html); context.html = node; } } ``` 反序列化则是相反的操作,判断当前正在处理的`HTML`节点是否为标题节点,如果是的话就将其转换为`Node`节点。这里同样需要原地处理数据,与行内节点不同的是,需要使用`applyLineMarker`将所有的行节点加入标题格式。 ```js // packages/plugin/src/heading/index.tsx export class HeadingPlugin extends BlockPlugin { public deserialize(context: PasteContext): void { const { nodes, html } = context; if (!isHTMLElement(html)) return void 0; const tagName = html.tagName.toLocaleLowerCase(); if (tagName.startsWith("h") && tagName.length === 2) { let level = Number(tagName.replace("h", "")); if (level <= 0 || level > 3) level = 3; // applyLineMarker packages/plugin/src/clipboard/utils/apply.ts context.nodes = applyLineMarker(this.editor, nodes, { [HEADING_KEY]: { type: `h` + level, id: getId() }, }); } } } ``` ### 组合结构 组合结构在这里指的是引用块、有序列表、无序列表等结构样式,这里则以引用块为例来处理序列化与反序列化。序列化组合结构,同样需要`Node`是引用块节点时,构造相关的`HTML`节点进行包装。 ```js // packages/plugin/src/quote-block/index.tsx export class QuoteBlockPlugin extends BlockPlugin { public serialize(context: CopyContext): void { const element = context.node as BlockElement; const quote = element[QUOTE_BLOCK_KEY]; if (!quote) return void 0; const node = document.createElement("blockquote"); node.setAttribute("data-type", QUOTE_BLOCK_KEY); node.appendChild(context.html); context.html = node; } } ``` 反序列化同样是判断是否为引用块节点,并且构造对应的`Node`节点。这里与标题模块不同的是,标题是将格式应用到相关的行节点上,而引用块则是在原本的节点上嵌套一层结构。 ```js // packages/plugin/src/quote-block/index.tsx export class QuoteBlockPlugin extends BlockPlugin { public deserialize(context: PasteContext): void { const { nodes, html } = context; if (!isHTMLElement(html)) return void 0; if (isMatchTag(html, "blockquote")) { const current = applyLineMarker(this.editor, nodes, { [QUOTE_BLOCK_ITEM_KEY]: true, }); context.nodes = [{ children: current, [QUOTE_BLOCK_KEY]: true }]; } } } ``` ### 嵌入结构 嵌入结构在这里指的是图片、视频、流程图等结构样式,这里则以图片为例来处理序列化与反序列化。序列化嵌入结构,我们只需要`Node`是图片节点时,构造相关的`HTML`节点进行包装。与之前的节点不同的是,此时我们不需要嵌套`DOM`节点了,将独立节点原地替换即可。 ```js // packages/plugin/src/image/index.tsx export class ImagePlugin extends BlockPlugin { public serialize(context: CopyContext): void { const element = context.node as BlockElement; const img = element[IMAGE_KEY]; if (!img) return void 0; const node = document.createElement("img"); node.src = img.src; node.setAttribute("data-type", IMAGE_KEY); node.appendChild(context.html); context.html = node; } } ``` 对于反序列化的结构,判断当前正在处理的`HTML`节点是否为图片节点,如果是的话就将其转换为`Node`节点。与先前的转换不同的是,我们此时不需要嵌套结构,只需要固定`children`为零宽字符占位即可。实际上这里还有个常用的操作是,粘贴图片内容通常需要将原本的`src`转储到我们的服务上,例如飞书的图片就是临时链接,在生产环境中需要转储资源。 ```js // packages/plugin/src/image/index.tsx export class ImagePlugin extends BlockPlugin { public deserialize(context: PasteContext): void { const { html } = context; if (!isHTMLElement(html)) return void 0; if (isMatchTag(html, "img")) { const src = html.getAttribute("src") || ""; const width = html.getAttribute("data-width") || 100; const height = html.getAttribute("data-height") || 100; context.nodes = [ { [IMAGE_KEY]: { src: src, status: IMAGE_STATUS.SUCCESS, width: Number(width), height: Number(height), }, uuid: getId(), children: [{ text: "" }], }, ]; } } } ``` ### 块级结构 块级结构指的是高亮块、代码块、表格等结构样式,这里则以高亮块为例来处理序列化与反序列化。高亮块则是飞书中比较定制的结构,本质上是`Editable`结构的嵌套,这里的两层`callout`嵌套结构则是为了兼容飞书的结构。序列化块级结构在`slate`中跟引用结构类似,在外层直接嵌套组合结构即可。 ```js // packages/plugin/src/highlight-block/index.tsx export class HighlightBlockPlugin extends BlockPlugin { public serialize(context: CopyContext): void { const { node: node, html } = context; if (this.reflex.isBlock(node) && node[HIGHLIGHT_BLOCK_KEY]) { const colors = node[HIGHLIGHT_BLOCK_KEY]!; // 提取具体色值 const border = colors.border || ""; const background = colors.background || ""; const regexp = /rgb\((.+)\)/; const borderVar = RegExec.exec(regexp, border); const backgroundVar = RegExec.exec(regexp, background); const style = window.getComputedStyle(document.body); const borderValue = style.getPropertyValue(borderVar); const backgroundValue = style.getPropertyValue(backgroundVar); // 构建 HTML 容器节点 const container = document.createElement("div"); container.setAttribute(HL_DOM_TAG, "true"); container.classList.add("callout-container"); container.style.border = `1px solid rgb(` + borderValue + `)`; container.style.background = `rgb(` + backgroundValue + `)`; container.setAttribute("data-emoji-id", "balloon"); const block = document.createElement("div"); block.classList.add("callout-block"); container.appendChild(block); block.appendChild(html); context.html = container; } } } ``` 反序列化则是判断当前正在处理的`HTML`节点是否为高亮块节点,如果是的话就将其转换为`Node`节点。这里的处理方式同样与引用块类似,只是需要在外层嵌套一层结构。 ```js // packages/plugin/src/highlight-block/index.tsx export class HighlightBlockPlugin extends BlockPlugin { public deserialize(context: PasteContext): void { const { nodes, html: node } = context; if (isHTMLElement(node) && node.classList.contains("callout-block")) { const border = node.style.borderColor; const background = node.style.backgroundColor; const regexp = /rgb\((.+)\)/; const borderColor = border && RegExec.exec(regexp, border); const backgroundColor = background && RegExec.exec(regexp, background); if (!borderColor || !backgroundColor) return void 0; context.nodes = [ { [HIGHLIGHT_BLOCK_KEY]: { border: borderColor, background: backgroundColor, }, children: nodes, }, ]; } } } ``` ## 扁平数据类型 `quill`的基本数据结构是扁平结构的`JSON`类型,相关的`DEMO`实现都在`https://github.com/WindRunnerMax/BlockKit`中。我们同样以标题与加粗的格式为例,描述其基础内容结构: ```js [ { insert: "Editor" }, { attributes: { heading: "h1" }, insert: "\n" }, { attributes: { bold: "true" }, insert: "加粗" }, { insert: "格式" }, { insert: "\n" }, ]; ``` 序列化的调度方案与`slate`类似,我们同样需要在剪贴板模块提供基本的序列化与反序列化的接口,而具体的实现则归于插件本身处理。针对序列化的方法,也是按照基本行遍历的方式,优先处理`Delta`结构的的文本,再处理行结构的格式。但是由于`delta`的数据结构是扁平的,因此我们不能直接递归处理,而是应该循环到`EOL`时将当前行的节点更新为新的行节点。 ```js // packages/core/src/clipboard/modules/copy.ts const root = rootNode || document.createDocumentFragment(); let lineFragment = document.createDocumentFragment(); const ops = normalizeEOL(delta.ops); for (const op of ops) { if (isEOLOp(op)) { const context: SerializeContext = { op, html: lineFragment }; this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context); let lineNode = context.html as HTMLElement; if (!isMatchBlockTag(lineNode)) { lineNode = document.createElement("div"); lineNode.setAttribute(LINE_TAG, "true"); lineNode.appendChild(context.html); } root.appendChild(lineNode); lineFragment = document.createDocumentFragment(); continue; } const text = op.insert || ""; const textNode = document.createTextNode(text); const context: SerializeContext = { op, html: textNode }; this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context); lineFragment.appendChild(context.html); } ``` 反序列化的整体流程则与`slate`更加类似,因为我们同样都是以`HTML`为基准处理数据,深度递归遍历优先处理叶子节点,然后以处理过的`delta`为基准处理额外节点。只不过这里我们最终输出的数据结构会是扁平的,这样的话就不需要特别关注`Normalize`的操作。 ```js // packages/core/src/clipboard/modules/paste.ts public deserialize(current: Node): Delta { const delta = new Delta(); // 结束条件 Text Image 等节点都会在此时处理 if (!current.childNodes.length) { if (isDOMText(current)) { const text = current.textContent || ""; delta.insert(text); } else { const context: DeserializeContext = { delta, html: current }; this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context); return context.delta; } return delta; } const children = Array.from(current.childNodes); for (const child of children) { const newDelta = this.deserialize(child); delta.ops.push(...newDelta.ops); } const context: DeserializeContext = { delta, html: current }; this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context); return context.delta; } ``` 此外,对于块级嵌套结构的处理,我们的处理方式可能会更加复杂,但是在当前的实现中还并没有完成,因此暂时还处于设计阶段。序列化的处理方式类似于下面的流程,与先前结构不同的是,当处理到块结构时,直接调用剪贴板的序列化模块,将内容嵌入即可。 ``` | -- bold ··· -- | | -- line -- | | --
---| | | -- text ··· ---- | | | | root -- lines -- | -- line -- leaves ··· ---------
---| -- normalize -- html | | | -- codeblock -- ref(id) ··· -------
---| | | | -- table -- ref(id) ··· ----------
---| ``` 反序列化的方式相对更复杂一些,因为我们需要维护嵌套结构的引用关系。虽然本身经过`DOMParser`解析过后的`HTML`是嵌套的内容,但是我们的基准解析方法目标是扁平的`Delta`结构,然而`block`、`table`等结构的形式是需要嵌套引用的结构,这个`id`的关系就需要我们以约定的形式完成。 ``` | -- -- text ··· text|r -- bold|r -- | | -- --

-- | | -- head|r -- align|r -- | | | -- -- text ··· text|r -- link|r -- | | -- | | -- deltas | | -- -- text ··· text|r -- unl|r --- | | | -- --
-- | | -- block|id -- ref|r -- | | -- -- text ··· text|r -- em|r ---- | ``` 接下来我们将会以`delta`数据结构为例,处理扁平结构的剪贴板模块设计。同样分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,在上述基本模式的调度下,分类型进行序列化与反序列化的插件实现。 ### 行内结构 行内结构指的是加粗、斜体、下划线、删除线、行内代码块等行内的结构样式,这里以加粗为例来处理序列化与反序列化。序列化行内结构部分基本与`slate`一致,从这里开始我们采用单元测试的方式执行。 ```js // packages/core/test/clipboard/bold.test.ts it("serialize", () => { const plugin = getMockedPlugin({ serialize(context) { if (context.op.attributes?.bold) { const strong = document.createElement("strong"); strong.appendChild(context.html); context.html = strong; } }, }); editor.plugin.register(plugin); const delta = new Delta().insert("Hello", { bold: "true" }).insert("World"); const root = editor.clipboard.copyModule.serialize(delta); const plainText = getFragmentText(root); const htmlText = serializeHTML(root); expect(plainText).toBe("HelloWorld"); expect(htmlText).toBe(`
HelloWorld
`); }); ``` 反序列化部分则是判断当前正在处理的`HTML`节点是否为加粗节点,如果是的话就将其转换为`Delta`节点。 ```js // packages/core/test/clipboard/bold.test.ts it("deserialize", () => { const plugin = getMockedPlugin({ deserialize(context) { const { delta, html } = context; if (!isHTMLElement(html)) return void 0; if (isMatchHTMLTag(html, "strong") || isMatchHTMLTag(html, "b") || html.style.fontWeight === "bold") { // applyMarker packages/core/src/clipboard/utils/deserialize.ts applyMarker(delta, { bold: "true" }); } }, }); editor.plugin.register(plugin); const parser = new DOMParser(); const transferHTMLText = `
HelloWorld
`; const html = parser.parseFromString(transferHTMLText, "text/html"); const rootDelta = editor.clipboard.pasteModule.deserialize(html.body); const delta = new Delta().insert("Hello", { bold: "true" }).insert("World"); expect(rootDelta).toEqual(delta); }); ``` ### 段落结构 段落结构指的是标题、行高、文本对齐等结构样式,这里则以标题为例来处理序列化与反序列化。序列化段落结构,我们只需要`Node`是标题节点时,构造相关的`HTML`节点,将本来的节点原地包装并赋值到`context`即可,同样采用嵌套节点的方式。 ```js // packages/core/test/clipboard/heading.test.ts it("serialize", () => { const plugin = getMockedPlugin({ serialize(context) { const { op, html } = context; if (isEOLOp(op) && op.attributes?.heading) { const element = document.createElement(op.attributes.heading); element.appendChild(html); context.html = element; } }, }); editor.plugin.register(plugin); const delta = new MutateDelta().insert("Hello").insert("\n", { heading: "h1" }); const root = editor.clipboard.copyModule.serialize(delta); const plainText = getFragmentText(root); const htmlText = serializeHTML(root); expect(plainText).toBe("Hello"); expect(htmlText).toBe(`

Hello

`); }); ``` 反序列化则是相反的操作,判断当前正在处理的`HTML`节点是否为标题节点,如果是的话就将其转换为`Node`节点。这里同样需要原地处理数据,与行内节点不同的是,需要使用`applyLineMarker`将所有的行节点加入标题格式。 ```js // packages/core/test/clipboard/heading.test.ts it("deserialize", () => { const plugin = getMockedPlugin({ deserialize(context) { const { delta, html } = context; if (!isHTMLElement(html)) return void 0; if (["h1", "h2"].indexOf(html.tagName.toLowerCase()) > -1) { applyLineMarker(delta, { heading: html.tagName.toLowerCase() }); } }, }); editor.plugin.register(plugin); const parser = new DOMParser(); const transferHTMLText = `

Hello

World

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

Hello

World

`; const html = parser.parseFromString(transferHTMLText, TEXT_HTML); const rootDelta = editor.clipboard.pasteModule.deserialize(html.body); const delta = new Delta() .insert("Hello") .insert("\n", { quote: "true" }) .insert("World") .insert("\n", { quote: "true" }); expect(rootDelta).toEqual(MutateDelta.from(delta)); }); ``` ### 嵌入结构 嵌入结构在这里指的是图片、视频、流程图等结构样式,这里则以图片为例来处理序列化与反序列化。序列化嵌入结构,我们只需要`Node`是图片节点时,构造相关的`HTML`节点进行包装。与之前的节点不同的是,此时我们不需要嵌套`DOM`节点了,将独立节点原地替换即可。 ```js // packages/core/test/clipboard/image.test.ts it("serialize", () => { const plugin = getMockedPlugin({ serialize(context) { const { op } = context; if (op.attributes?.image && op.attributes.src) { const element = document.createElement("img"); element.src = op.attributes.src; context.html = element; } }, }); editor.plugin.register(plugin); const delta = new Delta().insert(" ", { image: "true", src: "https://example.com/image.png", }); const root = editor.clipboard.copyModule.serialize(delta); const plainText = getFragmentText(root); const htmlText = serializeHTML(root); expect(plainText).toBe(""); expect(htmlText).toBe(`
`); }); ``` 对于反序列化的结构,判断当前正在处理的`HTML`节点是否为图片节点,如果是的话就将其转换为`Node`节点。同样的,这里还有个常用的操作是,粘贴图片内容通常需要将原本的`src`转储到我们的服务上,例如飞书的图片就是临时链接,在生产环境中需要转储资源。 ```js // packages/core/test/clipboard/image.test.ts it("deserialize", () => { const plugin = getMockedPlugin({ deserialize(context) { const { html } = context; if (!isHTMLElement(html)) return void 0; if (isMatchHTMLTag(html, "img")) { const src = html.getAttribute("src") || ""; const delta = new Delta(); delta.insert(" ", { image: "true", src: src }); context.delta = delta; } }, }); editor.plugin.register(plugin); const parser = new DOMParser(); const transferHTMLText = ``; const html = parser.parseFromString(transferHTMLText, TEXT_HTML); const rootDelta = editor.clipboard.pasteModule.deserialize(html.body); const delta = new Delta().insert(" ", { image: "true", src: "https://example.com/image.png" }); expect(rootDelta).toEqual(delta); }); ``` ### 块级结构 ### 块级结构 块级结构指的是高亮块、代码块、表格等结构样式,这里则以块结构为例来处理序列化与反序列化。这里的嵌套结构还没有实现,因此这里仅仅是实现了上述`deltas`图示的测试用例,主要的处理方式是当存在引用关系时,主动调用序列化的方式将其写入到`HTML`中。 ```js it("serialize", () => { const block = new Delta().insert("inside"); const inside = editor.clipboard.copyModule.serialize(block); const plugin = getMockedPlugin({ serialize(context) { const { op } = context; if (op.attributes?._ref) { const element = document.createElement("div"); element.setAttribute("data-block", op.attributes._ref); element.appendChild(inside); context.html = element; } }, }); editor.plugin.register(plugin); const delta = new Delta().insert(" ", { _ref: "id" }); const root = editor.clipboard.copyModule.serialize(delta); const plainText = getFragmentText(root); const htmlText = serializeHTML(root); expect(plainText).toBe("inside\n"); expect(htmlText).toBe( `
inside
` ); }); ``` 反序列化则是判断当前正在处理的`HTML`节点是否为块级节点,如果是的话就将其转换为`Node`节点。这里的处理方式则是,深度优先遍历处理节点内容时,若是出现`block`节点,则生成`id`并放置于`deltas`中,然后在`ROOT`结构中引用该节点。 ```js it("deserialize", () => { const deltas: Record = {}; const plugin = getMockedPlugin({ deserialize(context) { const { html } = context; if (!isHTMLElement(html)) return void 0; if (isMatchHTMLTag(html, "div") && html.hasAttribute("data-block")) { const id = html.getAttribute("data-block")!; deltas[id] = context.delta; context.delta = new Delta().insert(" ", { _ref: id }); } }, }); editor.plugin.register(plugin); const parser = new DOMParser(); const transferHTMLText = `
inside
`; const html = parser.parseFromString(transferHTMLText, TEXT_HTML); const rootDelta = editor.clipboard.pasteModule.deserialize(html.body); deltas[ROOT_BLOCK] = rootDelta; expect(deltas).toEqual({ [ROOT_BLOCK]: new Delta().insert(" ", { _ref: "id" }), id: new Delta().insert("inside"), }); }); ``` ## 每日一题 - ## 参考 - - - - - -