初探富文本之序列化与反序列化
在富文本编辑器中,序列化与反序列化是非常重要的环节,其涉及到了编辑器的内容复制、粘贴、导入导出等模块。当用户在编辑器中进行复制操作时,富文本内容会被转换为标准的HTML
格式,并存储在剪贴板中。而在粘贴操作中,编辑器则需要将这些HTML
内容解析并转换为编辑器的私有JSON
结构,以便于实现跨编辑器内容的统一管理。
描述
我们平时在使用一些在线文档编辑器的时候,可能会好奇一个问题,为什么我们能够直接把格式复制出来,而不仅仅是纯文本,甚至于说从浏览器中复制内容到Office Word
都可以保留格式。这看起来是不是一件很神奇的事情,不过当我们了解到剪贴板的基本操作之后,就可以了解这其中的底层实现了。
说到剪贴板的操作,在执行复制行为的时候,我们可能会认为复制的就是纯文本,然而显然光靠复制纯文本我们是做不到上述的功能。所以实际上剪贴板是可以存储复杂内容的,那么在这里我们以Word
为例,当我们从Word
中复制文本时,其实际上是会在剪贴板中写入这么几个key
值:
text/plain
text/html
text/rtf
image/png
看着text/plain
是不是很眼熟,这显然就是我们常见的Content-Type
或者称作MIME-Type
,所以说我们是不是可以认为剪贴板是一个Record<string, string>
的结构类型。但是别忽略了我们还有一个image/png
类型,因为我们的剪贴板是可以直接复制文件的,所以我们常用的剪贴板类型就是Record<string, string | File>
,例如此时复制这段文字 在剪贴板中就是如下内容 。
text/plain
例如此时复制这段文字在剪贴板中就是如下内容
text/html
<meta charset="utf-8"><strong style="...">例如此时复制这段文字</strong><em style="...">在剪贴板中就是如下内容</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
。
document . execCommand ( "selectAll" ) ;
const res = document . execCommand ( "copy" ) ;
console . log ( res ) ; // true
const dataItems : Record < string , Blob > = { } ;
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
中才可以执行。
document . addEventListener ( "paste" , ( e ) => {
const data = e . clipboardData ;
console . log ( data ) ;
} ) ;
const res = document . execCommand ( "paste" ) ;
console . log ( res ) ; // false
navigator . clipboard . read ( ) . then ( res => {
for ( const item of res ) {
item . getType ( "text/html" ) . then ( console . log ) . catch ( ( ) => null )
}
} ) ;
当然这里并不是此时研究的重点,我们关注的是内容的序列化与反序列化,即在富文本编辑器的复制粘贴模块的设计。当然这个模块还会有更广泛的用途,例如序列化的场景有交付Word
文档、输出Markdown
格式等,反序列的场景有导入Markdown
文档等。而我们对于这个模块的设计,则需要考虑到以下几个问题:
插件化,编辑器中的模块本身都是插件化的,那么关于剪贴板模块的设计自然也需要能够自由扩展序列化/反序列化的格式。特别是在需要精确适配编辑器例如飞书、语雀等的私有格式时,需要能够自由控制相关行为。
普适性,由于富文本需要实现DOM
与选区MODEL
的映射,因此生成的DOM
结构通常会比较复杂。而当我们从文档中复制内容到剪贴板时,我们会希望这个结构是更规范化的,以便粘贴到其他平台例如飞书、Word
等时会有更好的解析。
完整性,当执行序列化与反序列时,希望能够保持内容的完整性,即不会因为这个的过程而丢失内容,这里相当于对性能做出让步而保持内容完整。而对于编辑器本身的格式则关注性能,由于实际注册的模块一致,希望能够直接应用数据而不需要走整个解析过程。
那么本文将会以slate
为例,处理嵌套结构的剪贴板模块设计,并且以quill
为例,处理扁平结构的剪贴板模块设计。并且以飞书文档的内容为例,分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,分类型进行序列化与反序列化的设计。
嵌套结构
slate
的基本数据结构是树形结构的JSON
类型,相关的DEMO
实现都在https://github.com/WindRunnerMax/DocEditor
中。我们先以标题与加粗的格式为例,描述其基础内容结构:
[
{ children : [ { text : "Editor" } ] , heading : { type : "h1" , id : "W5xjbuxy" } } ,
{ children : [ { text : "加粗" , bold : true } , { text : "格式" } ] } ,
] ;
实际上slate
的数据结构形式非常类似于DOM
结构的嵌套格式,甚至于DOM
结构与数据结构是完全一一对应的,例如在渲染Embed
结构中的零宽字符渲染时也会在数据结构中存在。因此在实现序列化与反序列化的过程中,理论上我们是可以直接实现其JSON
结构完全对应为DOM
结构的转换。
然而完全对应的情况只是理想情况下,富文本编辑器对于内容的实际组织形式可能会多种多样,例如实现引用块结构时,外层包裹的blockquote
标签可能是数据结构本身存在,也可能是渲染时根据行属性动态渲染的,这种情况下就不能直接从数据结构的层面上将其序列化为完整的HTML
。
// 结构渲染
[
{
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
数据。
{
"text/plain" : "Editor\n加粗格式" ,
"text/html" : "<h1 id=\"W5xjbuxy\">Editor</h1><div data-line><strong>加粗</strong>格式</div>" ,
"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
类型不被支持。
( async ( ) => {
await new Promise ( ( resolve ) => setTimeout ( resolve , 3000 ) ) ;
const params = {
"text/plain" : "Editor" ,
"text/html" : "<span>Editor</span>" ,
"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
元素来实现。
const data = {
"text/plain" : "Editor" ,
"text/html" : "<span>Editor</span>" ,
"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
定义,当即将写入剪贴板时会被调用。
// 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
节点即可。
// packages/core/src/plugin/modules/declare.ts
public call < T extends CallerType > ( 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
节点插入到行节点中。
// 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
节点上加入了额外的标识,这样可以帮助我们在反序列化的时候能够幂等地处理。
// 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
树形结构的处理。
// 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
定义。
// 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
应用到编辑器中。
// 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
对象进行解析,然后深度优先遍历由内而外处理每个节点,具体的实现依然需要调度插件来处理。
// 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
节点,注意的是我们需要原地处理。
// 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
,但是这里就简单地处理空节点数据。
// 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: 兜底处理无文本节点的情况 例如 <div><div></div></div>
if ( node . children && ! node . children . length ) {
node . children . push ( { text : "" } ) ;
}
}
}
}
对于内容的处理则是判断出HTML
节点存在加粗的格式后,对当前已经处理的Node
节点树中所有的文本节点实现加粗操作,这里同样需要原地处理数据。这里我们还封装了applyMark
的方法,用来处理所有的文本节点格式。其实这里有趣的是,因为我们的目标是构造整个JSON
,我们就不需要关注使用slate
的Transform
模块操作Model
。
// 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
即可,同样采用嵌套节点的方式。
// 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
将所有的行节点加入标题格式。
// 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
节点进行包装。
// 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
节点。这里与标题模块不同的是,标题是将格式应用到相关的行节点上,而引用块则是在原本的节点上嵌套一层结构。
// 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
节点了,将独立节点原地替换即可。
// 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
转储到我们的服务上,例如飞书的图片就是临时链接,在生产环境中需要转储资源。
// 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
中跟引用结构类似,在外层直接嵌套组合结构即可。
// 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
节点。这里的处理方式同样与引用块类似,只是需要在外层嵌套一层结构。
// 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
中。我们同样以标题与加粗的格式为例,描述其基础内容结构:
[
{ insert : "Editor" } ,
{ attributes : { heading : "h1" } , insert : "\n" } ,
{ attributes : { bold : "true" } , insert : "加粗" } ,
{ insert : "格式" } ,
{ insert : "\n" } ,
] ;
序列化的调度方案与slate
类似,我们同样需要在剪贴板模块提供基本的序列化与反序列化的接口,而具体的实现则归于插件本身处理。针对序列化的方法,也是按照基本行遍历的方式,优先处理Delta
结构的的文本,再处理行结构的格式。但是由于delta
的数据结构是扁平的,因此我们不能直接递归处理,而是应该循环到EOL
时将当前行的节点更新为新的行节点。
// 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
的操作。
// 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 ··· <strong> -- |
| -- line -- | | -- <div> ---|
| | -- text ··· <span> ---- | |
| |
root -- lines -- | -- line -- leaves ··· <elements> --------- <div> ---| -- normalize -- html
| |
| -- codeblock -- ref(id) ··· <code> ------- <div> ---|
| |
| -- table -- ref(id) ··· <table> ---------- <div> ---|
反序列化的方式相对更复杂一些,因为我们需要维护嵌套结构的引用关系。虽然本身经过DOMParser
解析过后的HTML
是嵌套的内容,但是我们的基准解析方法目标是扁平的Delta
结构,然而block
、table
等结构的形式是需要嵌套引用的结构,这个id
的关系就需要我们以约定的形式完成。
| -- <b> -- text ··· text|r -- bold|r -- |
| -- <align> -- <h1> -- | | -- head|r -- align|r -- |
| | -- <a> -- text ··· text|r -- link|r -- | |
<body> -- | | -- deltas
| | -- <u> -- text ··· text|r -- unl|r --- | |
| -- <code> -- <div> -- | | -- block|id -- ref|r -- |
| -- <i> -- text ··· text|r -- em|r ---- |
接下来我们将会以delta
数据结构为例,处理扁平结构的剪贴板模块设计。同样分别以行内结构、段落结构、组合结构、嵌入结构、块级结构为基础,在上述基本模式的调度下,分类型进行序列化与反序列化的插件实现。
行内结构
行内结构指的是加粗、斜体、下划线、删除线、行内代码块等行内的结构样式,这里以加粗为例来处理序列化与反序列化。序列化行内结构部分基本与slate
一致,从这里开始我们采用单元测试的方式执行。
// 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 ( ` <div data-node="true"><strong>Hello</strong>World</div> ` ) ;
} ) ;
反序列化部分则是判断当前正在处理的HTML
节点是否为加粗节点,如果是的话就将其转换为Delta
节点。
// 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 = ` <div><strong>Hello</strong>World</div> ` ;
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
即可,同样采用嵌套节点的方式。
// 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 ( ` <h1>Hello</h1> ` ) ;
} ) ;
反序列化则是相反的操作,判断当前正在处理的HTML
节点是否为标题节点,如果是的话就将其转换为Node
节点。这里同样需要原地处理数据,与行内节点不同的是,需要使用applyLineMarker
将所有的行节点加入标题格式。
// 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 = ` <div><h1>Hello</h1><h2>World</h2></div> ` ;
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
节点进行包装。在扁平结构下类似组合结构的处理方式会是渲染时进行的,因此序列化的过程与先前标题一致。
// 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 ( ` <blockquote>Hello</blockquote> ` ) ;
} ) ;
反序列化同样是判断是否为引用块节点,并且构造对应的Node
节点。这里与标题模块不同的是,标题是将格式应用到相关的行节点上,而引用块则是在原本的节点上嵌套一层结构。反序列化的结构处理方式也类似于标题处理方式,由于在HTML
的结构上是嵌套结构,在应用时在所有行节点上加入引用格式。
// 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 = ` <div><blockquote><p>Hello</p><p>World</p></blockquote></div> ` ;
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
节点了,将独立节点原地替换即可。
// 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 ( ` <div data-node="true"><img src="https://example.com/image.png"></div> ` ) ;
} ) ;
对于反序列化的结构,判断当前正在处理的HTML
节点是否为图片节点,如果是的话就将其转换为Node
节点。同样的,这里还有个常用的操作是,粘贴图片内容通常需要将原本的src
转储到我们的服务上,例如飞书的图片就是临时链接,在生产环境中需要转储资源。
// 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 = ` <img src="https://example.com/image.png"></img> ` ;
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
中。
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 (
` <div data-node="true"><div data-block="id"><div data-node="true">inside</div></div></div> `
) ;
} ) ;
反序列化则是判断当前正在处理的HTML
节点是否为块级节点,如果是的话就将其转换为Node
节点。这里的处理方式则是,深度优先遍历处理节点内容时,若是出现block
节点,则生成id
并放置于deltas
中,然后在ROOT
结构中引用该节点。
it ( "deserialize" , ( ) => {
const deltas : Record < string , Delta > = { } ;
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 = ` <div data-node="true"><div data-block="id"><div data-node="true">inside</div></div></div> ` ;
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" ) ,
} ) ;
} ) ;
每日一题
参考