clipboard:复制粘贴的序列化与解析

5 分钟阅读
·

view 部分还剩三块内容:剪贴板、坐标换算、浏览器补丁。这篇先看剪贴板。复制粘贴看起来是浏览器白送的功能,真接手过来会发现事情不少:选区可能从中间切开节点,切出来的片段直接序列化成 HTML 会丢掉结构信息;粘进来的 HTML 可能是自己写的,也可能是 Word、网页、终端里来的,结构完全不可控。prosemirror-view 用一个文件处理这两个方向,参考代码是 prosemirror-view 的 ca4c78e,文件是 src/clipboard.ts,配套的 model 层 API(parseSlice、Slice.maxOpen)参考 prosemirror-model 的 6264de0,transaction 的插入方法参考 prosemirror-state 的 ffad5d9。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析(本篇)

复制出去:serializeForClipboard

入口是 serializeForClipboard(view, slice)。slice 来自 selection.content(),它的 openStart/openEnd 记着选区两端各自切开了几层节点(第 8 篇讲过这个约定)。函数开头先给插件一次机会:transformCopied prop 可以整体换掉这个 slice。之后做三件事:降深度、序列化 DOM、把丢失的信息写进 HTML。

降深度是一个 while 循环:只要 openStart 和 openEnd 都大于 1,且当前 content 只有一个孩子、这个孩子也只有一个孩子,就往下走一层,openStart、openEnd 各减一,并把经过节点的类型名和 attrs 记进 context 数组。attrs 与 defaultAttrs 相同时记 null,省掉冗余。这个循环的含义是:连续的单子链在 HTML 里序列化出来也只是几层嵌套标签,保留它们的打开深度没有价值,不如把 slice 收到真正分叉的那一层,把链上的节点信息挪到 context 里另存。比如从一个三层嵌套列表的最里层复制一段文字,slice 原本 openStart 是 3,循环走完可能降到 1,context 里留下两对「类型名 + attrs」。

序列化用 clipboardSerializer prop,没有就 DOMSerializer.fromSchema 现场建一个(第 9 篇的对象)。渲染目标是一个 div,挂在 detachedDoc() 返回的离线 document 上,这份 document 由 document.implementation.createHTMLDocument 创建并缓存复用,避免污染页面的真实 DOM。

接下来是表格碎片的补救。slice 里如果只有一个 <td><tr>,直接 innerHTML 出来浏览器会把它们丢掉,这是 HTML 解析规则:td 只能出现在 tr 里,tr 只能出现在 table 里。文件底部的 wrapMap 列了这张对应表(注释里写明是从 jQuery 借来的技巧),thead/tbody/tr/td/th 等九个标签各自对应一串祖先标签,比如 td 对应 ["table", "tbody", "tr"]。序列化完成后检查 div 的第一个元素孩子,命中 wrapMap 就进入包装循环:从祖先数组的末尾往前,每轮创建一个祖先元素,把 div 里现有的全部孩子挪进去,再把这个元素挂回 div。拿 td 举例,三轮过后 div 里的结构是 table > tbody > tr > td,td 仍是最里层。wrappers 变量记下包了几层,后面要用。

最后一步,把元数据写到第一个元素孩子的 data-pm-slice 属性上,格式是:

openStart openEnd[ -wrappers] contextJSON

例如 1 1 -3 [["table_cell",null]],表示两端的打开深度、序列化时补了三层包装、context 里有一个 table_cell。这个属性是整套机制的关键:HTML 本身表达不了「这段内容是从列表项里切出来的」,ProseMirror 选择自己把信息塞进 HTML,粘贴时再读回来。别的应用拿到这段 HTML 时这个属性只是一个无害的 data 属性,不影响渲染。

纯文本那一份简单得多:clipboardTextSerializer prop 优先,否则 slice.content.textBetween(0, size, "\n\n"),块与块之间空一行。函数最后把 {dom, text, slice} 三样一起返回,调用方各取所需:复制事件用 dom 和 text 写剪贴板,拖拽事件还会把 slice 存进 view.dragging,拖完在同一个文档内落下时不用再解析一次。

粘贴进来:parseFromClipboard

parseFromClipboard(view, text, html, plainText, $context) 的入参里,text 和 html 分别来自 clipboardData.getData("text/plain")getData("text/html"),$context 是光标处的 ResolvedPos,后续所有判断都围绕它展开。

第一个分支决定按文本还是按 HTML 处理。asText 为真的条件是有 text,且满足三者之一:用户按了 Shift 强制纯文本粘贴、光标在 code 块里($context.parent.type.spec.code)、或者剪贴板根本没有 html。确定按文本处理后,先过一道 transformPastedText prop,插件可以在这里改写文本,第二个参数标明当前是 code 块还是强制纯文本。

code 块是最短的路径:整个文本换行统一成 \n 后做成一个文本节点,包成 openStart/openEnd 都为 0 的 slice,过一遍 transformPasted 直接返回,不经过任何解析。普通纯文本先问 clipboardTextParser prop,插件没接管就自己构造一个 div,按换行拆分(连续换行算一个分隔符),每段做成一个 <p>,里面的文本节点带上 $context.marks() 拿到的当前 marks,再用 DOMSerializer 序列化进去。也就是说纯文本粘进来会继承光标位置的加粗斜体,这个行为是这里决定的。构造出的 div 之后会走和 HTML 相同的 parseSlice 流程。

HTML 路径先经 transformPastedHTML prop 过一道,然后 readHTML(html) 把字符串变成 DOM。readHTML 做三件事:剥掉开头的 <meta> 标签(Word 这类来源会在 HTML 前面塞 meta);看第一个标签是否命中 wrapMap,命中就在字符串两侧补上对应的祖先标签再 innerHTML,和序列化时的包装正好对称,解析完再钻回包装层里面;maybeWrapTrusted 处理 TrustedTypes CSP 下 innerHTML 被拦的情况,用一个名为 ProseMirrorClipboard 的 policy 包一层。Webkit 系浏览器还有额外一步 restoreReplacedSpaces:Chrome 会把不换行空格包在无 class 无 style 的 span 里,Safari 用 Apple-converted-space 这个 class,这里把它们换回普通空格。

DOM 就位后查 data-pm-slice。正则 /^(\d+) (\d+)(?: -(\d+))? (.*)/ 解出打开深度、包装层数、context 四段。有包装层数就把解析根节点往里钻对应的层数,跳过序列化时补的 table/tbody/tr,让 parser 从真实内容开始看。这里有个隐含的分工:data-pm-slice 只有在「从 ProseMirror 复制、粘回 ProseMirror」的往返里才存在,两个实例甚至两个页面之间都成立,因为信息全在 HTML 字符串里。前提是两边的 schema 认识同样的节点名,后面 addContext 查不到类型时会自动降级,跨 schema 粘贴退化成普通的外来 HTML。

解析本身交给 clipboardParser prop、domParser prop 或 DOMParser.fromSchema,调的是 parseSlice 而不是 parse,两者差别在第 10 篇讲过:parseSlice 不假设 DOM 是完整文档,返回带打开深度的 slice。options 里两个值值得注意。preserveWhitespace 在按文本处理或命中 data-pm-slice 时为真,含义是自己产生的数据保留空白,外来 HTML 按惯例折叠。ruleFromNode 挂了一条临时规则:父元素不在 inlineParents 那张内联标签表里的、最后一个 <br> 直接 ignore,这是各浏览器给块元素补尾的习惯,不拦的话每个段落末尾会多出一个 hard_break。

恢复打开结构的两条路

parseSlice 给出的 slice 还没有符合 schema 的两端结构,接下来分两种情况。

HTML 是自己序列化出去的(有 sliceData):先 closeSlice(slice, openStart, openEnd),把 slice 两端收拢到复制时记录的深度。注意这里的方向:parseSlice 返回前过了一道 Slice.maxOpen,解析结果的打开深度是按内容自然张开的最大值,可能深于复制时的记录;closeSlice 只在记录深度小于解析深度时动手,沿两端第一个、最后一个孩子往下,用 closeRange 递归调 contentMatchAt(...).fillBefore,按 schema 的内容表达式生成缺失的节点填进去,把多打开的那几层补全封口,和 transform 里 Fitter 用的是同一套 fillBefore 机制。比如复制时记录的 openStart 是 1,而解析出的 slice 打开深度到了 2,closeSlice 会在多打开的那一层补出内容表达式要求的前置节点,把这一侧降回 1;解析深度本就不超过记录值时,它什么也不做。然后 addContext(slice, contextJSON) 把 context 里记的节点类型一层层包回去,openStart、openEnd 相应加回去。类型名查不到、或该类型有必填 attrs 时中途停下,包不动的部分就放弃,不会报错。走完这两步,从列表项里切出来的内容粘回列表时还是列表项里的内容,结构信息完成了一次往返。

HTML 是外来的(没有 sliceData):先 normalizeSiblings(slice.content, $context) 处理顶层兄弟节点不兼容的问题,再 Slice.maxOpen(content, true) 把 slice 尽量打开。maxOpen 的语义是沿两端第一个、最后一个孩子一路下降,遇到的每一层非叶节点都算作打开的一层,让 slice 的打开深度尽可能大,这样粘进当前结构时两端能最大程度地和光标两侧的节点合并,从网页复制三段文字粘进一个段落时,得到的是文字接进原段落,不会在段落外叠三个新段落。第二个参数 openIsolating 传 true,表示下降时可以穿过 isolating 节点。打开之后还有一个收敛循环做反向修正:从两端沿第一个/最后一个孩子往下数,遇到 isolating 节点就停,数出实际允许打开的深度,再 closeSlice 到这个深度。isolating 节点的内容不允许和外部结构直接衔接,这个检查保证打开操作不会穿透它,前面 maxOpen 放过去的深度在这里被收回来。

normalizeSiblings 值得单独看。场景是外来 HTML 解出的顶层有多个节点,而它们在当前位置拼不出合法序列,比如两个 <li> 并列(缺 ul/ol 外壳),或者一个标题跟着一个列表项。函数从 $context.depth 往 0 逐层试:取那一层父节点在光标 index 处的 contentMatchAt,对每个顶层节点调 findWrapping 找包裹链,全部找得到就用 withWrappers 包起来重建 fragment。处理过程中 contentMatch 也在前进,每包好一个节点就用 match.matchType 推进到下一个位置,后一个节点的包裹链是在前一个节点已经落位的前提下计算的。相邻节点包裹链相同前缀时会走 addToSibling 递归合并进同一个父节点,两个 li 合成一个 ul 就靠它;换包裹链时先用 closeRight 把前一个 sibling 按 fillBefore 补全封口。每一层试不出来就换更浅的一层,全失败则原样返回,把问题留给后续插入逻辑去失败。这段代码和第 17 篇 structure.ts 的 findWrapping 是同一件事的两个消费方。

两条路最后都过 transformPasted prop,插件有机会改写完的 slice。

到这里可以把这条链路上的 props 扩展面收拢一下。复制方向有 transformCopied、clipboardSerializer、clipboardTextSerializer 三个口;粘贴方向有 transformPastedHTML、transformPastedText、clipboardTextParser、clipboardParser、transformPasted、handlePaste 六个口。命名对应处理阶段:HTML 字符串、文本字符串、解析器、解析出的 slice、最终动作,每一层都能被插件换掉。这套粒度解释了为什么表格、Markdown 粘贴这类需求都能以插件形式存在,核心不需要为任何具体格式开口子。

doPaste 与事件入口

doPastesrc/input.ts)把上面所有东西收进一个 transaction:

let slice = parseFromClipboard(view, text, html, preferPlain, view.state.selection.$from)
if (view.someProp("handlePaste", f => f(view, event, slice || Slice.empty))) return true
if (!slice) return false
let singleNode = sliceSingleNode(slice)
let tr = singleNode
  ? view.state.tr.replaceSelectionWith(singleNode, preferPlain)
  : view.state.tr.replaceSelection(slice)
view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste"))

handlePaste 有第一优先权,插件返回 true 就整体接管,slice 解析失败时也拿一个空 slice 通知它。没被接管时,整个 slice 只含一个闭合节点(openStart/openEnd 为 0 且只有一个孩子)走 replaceSelectionWith,其余走 replaceSelection,让 transform 的闭合算法处理打开的两端。replaceSelectionWith 的第二个参数是 inheritMarks,src/transaction.ts 里默认值是 true,这里传入 preferPlain:Shift 强制纯文本粘贴时让插入节点继承光标处的 marks,和前面纯文本分支里 $context.marks() 的做法一致;普通粘贴不继承,因为 HTML 带来的内容自己带着 marks。transaction 打上 paste: trueuiEvent: "paste" 两个 meta,history 和 inputrules 这类插件靠它们识别粘贴,比如输入规则不该对粘贴进来的文本触发。

事件接线在 editHandlers.paste。composition 进行中直接放行让浏览器自己贴(Android 除外,那边的编辑器几乎永远在 composing)。剪贴板 API 可用的浏览器里,调 doPaste 成功就 preventDefault。view.input.shiftKey 且按下的不是 Insert 键时 preferPlain 为真,对应常见的 Ctrl+Shift+V 纯文本粘贴。还有一批浏览器(旧 IE、旧 iOS WebKit)clipboardData 对象存在但读写都是坏的,brokenClipboardAPI 探测出来后降级到 capturePaste:在页面上挂一个 position: fixed、left: -10000px 的 textarea 或 contentEditable div(纯文本场景用 textarea,否则用 div),focus 进去让浏览器完成原生粘贴,50 毫秒后读出内容再调 doPaste。复制一侧对称,handlers.copy = editHandlers.cut 序列化后同时写 text/html 和 text/plain,cut 额外 dispatch 一个 deleteSelection 并打上 uiEvent: "cut"。clipboardData 不可用时复制也有降级,captureCopy 把序列化出的 DOM 放到屏幕外、把选区设上去,让浏览器执行原生复制,50 毫秒后清理并恢复焦点。

同一对函数也服务拖拽。handlers.dragstart 用 serializeForClipboard 生成 dataTransfer 的内容,handleDrop 用 parseFromClipboard 解析落下的数据,差别只在插入位置的确定方式:粘贴用光标,拖放用 dropPoint 按落点坐标找一个能容纳该 slice 的位置。

剪贴板往返流程

小结

clipboard.ts 的核心问题可以归成一句话:HTML 是无结构的交换格式,Slice 是有打开深度的结构化数据,两个方向的转换都要补信息。出去时用 context 数组和 data-pm-slice 把 HTML 表达不了的信息塞进去,进来时靠 closeSlice、addContext 原样恢复;遇到不是自己产生的 HTML,就用 normalizeSiblings 和 maxOpen 在 schema 约束下现场推导一个能插进去的结构。text/plain 与 text/html 两份数据各管一段场景,code 块和 Shift 键是切换的开关。wrapMap 和它的解析侧镜像处理则提醒另一件事:剪贴板打交道的是真实浏览器的 HTML 解析器,表格碎片这类解析规则限制绕不过去,只能在序列化和解析两端同时补包装。下一篇看 domcoords.ts,屏幕坐标和文档位置的双向换算。


999 字 · 33 段落
xi ming

Written by xi ming You should follow him on Github