milkdown 插件实战:@milkdown/plugin-diff 差异审阅(Diff Review)完整指南
【免费下载链接】milkdown🍼 Plugin driven WYSIWYG markdown editor framework.项目地址: https://gitcode.com/GitHub_Trending/mi/milkdown
导读
@milkdown/plugin-diff是 milkdown 官方的差异审阅插件:它能够比较当前编辑器文档与另一份 Markdown(或已解析的 ProseMirror 文档)之间的差异,并通过 Accept/Reject 按钮让用户逐条接受或拒绝改动,审阅期间编辑器会自动锁定。本文以 docs/api/plugin-diff.md 为主线,结合 plugin-diff 源码 与 diff 组件源码 深入讲解插件的接入方式、全部命令 API、配置项、样式体系以及底层的 LCS 差异计算原理,读完即可在自有 milkdown 应用中落地一套完整的"文档审阅/版本对账"能力。
快速接入:在编辑器中使用 diff 插件
diff 插件分为两部分:负责差异计算与审阅状态的plugin(@milkdown/kit/plugin/diff),以及负责差异可视化渲染的component(@milkdown/kit/component/diff)。两者需要一起注册:
import { Editor } from '@milkdown/kit/core' import { diff } from '@milkdown/kit/plugin/diff' import { diffComponent } from '@milkdown/kit/component/diff' import { commonmark } from '@milkdown/kit/preset/commonmark' const editor = await Editor.make() .use(commonmark) .use(diff) .use(diffComponent) .create()从 plugin-diff 的 index.ts 可以看到,diff实际是一个由多个 Milkdown 插件组成的数组:diffConfig(配置上下文)、diffPlugin(ProseMirror 插件)以及 8 个命令插件;而 components/src/diff/index.ts 中的diffComponent则由diffComponentConfig与diffDecorationPlugin组成。组件负责把 plugin 计算出的Change列表翻译成 ProseMirror 的 inline/widget decorations 并渲染 Accept/Reject 控件。
在 Crepe 中使用
如果你使用的是 Crepe(milkdown 的开箱即用编辑器外壳),无需手动注册 diff 相关插件——Crepe 在启用 AI 功能时会自动带上 diff 能力:
import { Crepe, CrepeFeature } from '@milkdown/crepe' const crepe = new Crepe({ root: '#editor', features: { [CrepeFeature.AI]: true, }, }) await crepe.create()同时 Crepe 会预配置customBlockTypes: ['table', 'image-block', 'code_block'],并把milkdown-diff-*样式内置于主题 CSS 中(详见下文"自定义 Block 类型"与"样式"两节)。
开启一次差异审阅
方式一:传入 Markdown 字符串
通过callCommand调用startDiffReviewCmd,把修改后的 Markdown 传进去。插件内部会用parserCtx中的解析器把 Markdown 解析成目标文档,随后编辑器显示差异并锁定编辑,直到审阅完成:
import { callCommand } from '@milkdown/kit/utils' import { startDiffReviewCmd } from '@milkdown/kit/plugin/diff' editor.action( callCommand(startDiffReviewCmd.key, '# Updated content\n\nNew paragraph.') )从 diff-commands.ts 的实现可以看到,该命令先通过ctx.get(parserCtx)拿到解析器,把 Markdown 解析为Node,再以{ type: 'start', newDoc }的DiffAction通过tr.setMeta(diffPluginKey, ...)派发给 diff 插件。若传入的 Markdown 为空或解析失败(返回null),命令会返回false,不会开启审阅。
方式二:传入已解析的 ProseMirror Node
如果你在业务中已经持有目标文档的Node对象,可以调用startDiffReviewFromDocCmd,跳过"序列化 → 重新解析"的往返损耗,直接进入审阅:
import { startDiffReviewFromDocCmd } from '@milkdown/kit/plugin/diff' editor.action(callCommand(startDiffReviewFromDocCmd.key, someDocNode))该命令会校验传入节点的类型与当前文档根节点类型一致(newDoc.type !== state.doc.type时返回false),否则直接派发startaction(见 diff-commands.ts)。
审阅状态的内部流转
startaction 被 diff-plugin.ts 的插件apply阶段处理:立即用computeDocDiff计算当前文档与newDoc的差异,生成一个DiffState(active: true)。此后插件的filterTransaction开始生效:
- 带
diffPluginKeymeta 的事务(即插件自己派发的 accept/reject/clear 等)放行; - 其他任何会改动文档的事务一律被拦截(
tr.docChanged返回false),这就是审阅期间编辑器被锁定的原理; - 纯选区变化等不修改文档的事务仍然放行,用户仍可移动光标查看差异。
接受与拒绝改动
交互式操作
在 UI 上,每个差异块旁都会渲染 Accept / Reject 两个按钮(由 diff 组件生成)。按钮点击后通过commands.call(key, range)调用基于范围的命令acceptDiffRangeCmd/rejectDiffRangeCmd(见 diff-decoration-plugin.ts)。
编程式操作
所有审阅控制都可以通过命令编程完成:
import { callCommand } from '@milkdown/kit/utils' import { acceptAllDiffsCmd, clearDiffReviewCmd, acceptDiffChunkCmd, rejectDiffChunkCmd, } from '@milkdown/kit/plugin/diff' // 接受全部剩余改动 editor.action(callCommand(acceptAllDiffsCmd.key)) // 清空审阅(丢弃剩余改动,保留已接受的) editor.action(callCommand(clearDiffReviewCmd.key)) // 按索引接受/拒绝某一条改动 editor.action(callCommand(acceptDiffChunkCmd.key, 0)) editor.action(callCommand(rejectDiffChunkCmd.key, 0))各命令的底层行为(对应 diff-commands.ts):
acceptDiffChunkCmd(changeIndex):先从diffState.newDoc.slice(fromB, toB)取出目标内容并执行tr.replace,再附带acceptmeta。插件apply阶段检测到tr.docChanged会基于新文档重新计算差异,被接受的改动会自然地从新差异中消失(diff-plugin.ts)。rejectDiffChunkCmd(changeIndex):注意它发送的是fromB/toB而非索引——因为拒绝不会改动文档,而是把该改动区间记入rejectedRanges,而随着部分改动被拒绝,pending 列表的索引会漂移,用区间更稳定(diff-commands.ts)。acceptDiffRangeCmd(range)/rejectDiffRangeCmd(range):按DiffRange(fromA/toA/fromB/toB四个位置)操作,主要用于表格、图片块、代码块等自定义节点视图——这类节点内的多个子改动会被合并为一个可视块,必须用范围命令整体处理。acceptAllDiffsCmd:有一个快速路径——若rejectedRanges为空,直接一次replaceWith(0, doc.content.size, newDoc.content)替换整个文档,避免多次顺序替换造成的位置漂移;存在已拒绝项时才逐条应用剩余 pending 改动(diff-commands.ts)。clearDiffReviewCmd:只派发clearmeta,不做任何文档修改,直接退出审阅并解锁编辑器(保留已接受的改动,丢弃剩余差异)。
自动退出:当所有改动都被接受或拒绝后(getPendingChanges(result).length === 0),插件apply会返回null状态,diff 自动停用、编辑器解锁(diff-plugin.ts)。
Plugin 配置:diffConfig
diffConfig控制差异计算的规则,目前唯一配置项是ignoreAttrs:
import { diffConfig } from '@milkdown/kit/plugin/diff' Editor.make() .config((ctx) => { ctx.update(diffConfig.key, (prev) => ({ ...prev, ignoreAttrs: { heading: ['id'] }, // 默认值即 { heading: ['id'] } })) }) .use(diff) .use(diffComponent) .create()- 类型:
DiffConfig只有ignoreAttrs: Record<string, string[]>一个字段,含义是"按节点类型名映射到需要忽略的属性键数组"。 - 默认值:
{ heading: ['id'] }——即默认忽略标题节点的id属性,防止自动生成的 id 干扰差异计算(见 diff-config.ts)。 - 作用位置:该配置在每次
computeDocDiff调用时传入,由 token encoder 消费。ignoreAttrs在节点开始 token 的编码中生效:被忽略的属性不参与 token 生成,因此两个仅在忽略属性上不同的节点会被视为"相同"(见 diff-compute.ts)。
值得注意:ignoreAttrs还贯穿差异的递归与范围切分——祖先链一致性校验、子节点配对都复用 encoder 的 node-start token,因此忽略规则在块级配对、range模式的结构校验中同样生效。
Component 配置:diffComponentConfig
diffComponentConfig控制差异的可视化渲染,支持三个配置项:
import { diffComponentConfig } from '@milkdown/kit/component/diff' Editor.make() .config((ctx) => { ctx.update(diffComponentConfig.key, (prev) => ({ ...prev, acceptLabel: 'Apply', // 接受按钮文案(默认 'Accept') rejectLabel: 'Discard', // 拒绝按钮文案(默认 'Reject') customBlockTypes: [ // 使用自定义 node view 的节点类型 'table', 'image-block', 'code_block', ], })) }) .use(diff) .use(diffComponent) .create()DiffComponentConfig的完整定义(见 components/src/diff/config.ts):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
acceptLabel | string | 'Accept' | 接受按钮的文本 |
rejectLabel | string | 'Reject' | 拒绝按钮的文本 |
customBlockTypes | string[] | [] | 需要块级整体替换渲染的节点类型名列表 |
所有 diff 装饰的 CSS 类统一使用硬编码前缀DIFF_CLASS_PREFIX = 'milkdown-diff'——这是刻意为之,因为 Crepe 全部主题都依赖milkdown-diff-*选择器(config.ts)。
Custom Block Types:为什么自定义节点视图需要特殊处理
ProseMirror 的inline decoration 无法穿透自定义 node view(自定义视图内部由组件自己渲染 DOM,装饰无法注入)。因此对于使用自定义 node view 的节点(如表格table、图片块image-block、代码块code_block),如果差异发生在这些节点内部,diff 组件会退化为块级整体替换:
- 删除侧:整个节点打上"块级删除"覆盖样式;
- 插入侧:把目标文档中对应区间的节点逐个序列化为完整 DOM 结构后以块级 widget 插入(例如表格会渲染出完整的
<table>/<tbody>/<tr>结构,而非丢失结构的裸单元格内容,见 diff-decoration-plugin.ts); - 控件:整块渲染一组 Accept/Reject,且必须走范围命令(
acceptDiffRangeCmd/rejectDiffRangeCmd)——这正是这两个命令存在的意义。
使用 Crepe 时该列表已预配置为['table', 'image-block', 'code_block'],无需手动设置。
渲染细节:inline 与 block 两级差异展示
diff 组件的核心渲染逻辑在buildDecorations(diff-decoration-plugin.ts),它会先对 pending 改动做块级合并(mergeBlockChanges),再逐条决策渲染方式:
- 纯文本级改动:渲染为
inline装饰——删除用删除线,插入用行内 widget; - 跨块边界改动:拆分为 inline + block 两段分别渲染,同时保证整组差异只有一组控件;
- 删除仅覆盖尾部空段落(Crepe 等编辑器总是保留末尾空段)时直接跳过,避免视觉噪音;
- 块级 widget 的位置会通过
snapToBlockBoundary吸附到块边界,控件与新增内容对齐展示。
样式(Styling)
diff 组件输出的是带语义类名的 DOM,样式需要你自行提供(Crepe 场景下主题 CSS 已自动包含)。独立使用时的核心 CSS 类如下:
| Class | 说明 |
|---|---|
.milkdown-diff-removed | 行内删除(删除线) |
.milkdown-diff-removed-block | 块级删除(节点覆盖层) |
.milkdown-diff-added | 行内插入 |
.milkdown-diff-added-block | 块级插入 widget |
.milkdown-diff-controls | 行内 Accept/Reject 按钮容器 |
.milkdown-diff-controls-block | 块级 Accept/Reject 按钮容器 |
.milkdown-diff-accept | 接受按钮 |
.milkdown-diff-reject | 拒绝按钮 |
其中.milkdown-diff-controls与.milkdown-diff-controls-block是 Accept/Reject 两个按钮的公共容器,.milkdown-diff-accept/.milkdown-diff-reject是按钮本身。可以查看 storybook 的 diff 示例样式 以及 e2e 中的 diff 测试 获取可参考的完整样式实现。
深入原理:computeDocDiff 的差异计算
computeDocDiff是差异计算的核心(diff-compute.ts),它基于 ProseMirror 的ChangeSet实现,但做了两层关键增强:
1. Token encoder:把文档编码为可比对的 token 流
createDiffEncoder生成一个TokenEncoder<string | number>,负责把节点/字符编码成 token:
- 字符:
字符码:marks组合,mark 集合按类型排序后做 JSON 编码,并用WeakMap缓存单 mark 与 mark 集合的编码结果(ProseMirror 的 marks 按类型等级排序且结构共享,相同 mark 集合复用同一引用,缓存命中率高); - 节点开始:
节点类型名:非默认属性JSON——只编码与 spec 默认值不同的属性,且跳过ignoreAttrs指定的键; - 节点结束:负的节点类型 id(按 schema.nodes 顺序编号,缓存在
schema.cached.changeSetIDs)。
2. 逐块 LCS 匹配
与ChangeSet默认的全局比对不同,computeDocDiff采用逐块(per-block)LCS策略:
- 为每个容器节点的子节点计算"结构签名"(递归走一遍 token encoder),用 LCS 动态规划在旧/新子节点列表之间找最长公共子序列(
lcsMatch,O(n·m) 的 DP + 回溯); - LCS 匹配出的"间隙"用贪心策略处理:同类型节点配对比对,剩余的多余节点产出纯删除/纯插入;
- 可递归的容器(非 textblock、非 atom、非 code 的块节点)继续向下递归;文本块、原子节点、代码块以及属性有差异的节点,则走
diffPairWithChangeSet用单个ChangeSet精细比对; - 所有子结果的 ProseMirror 位置都会平移回绝对文档坐标,保证与编辑器选区、装饰位置一致。
3. 大数据量的兜底
容器子节点数超过LCS_MAX_CHILDREN = 500时(diff-compute.ts),直接退化为单步ChangeSet整体比对,避免 O(n·m) 的 DP 开销——这是从源码结构可以确认的明确性能保护阈值。
4. range 子区域差异
computeDocDiff还支持ComputeDocDiffOptions.range,把差异限制在某个[from, to)子区域内(两个文档使用相同的位置)。范围模式有严格的前置条件,不满足会抛出RangeError(源码中给出了具体错误信息):
- 边界对齐:两个端点必须落在共享祖先容器的兄弟边界上,不能落在 textblock 内部,也不能落在某个子节点的中间;
- 结构一致路径:从文档根到共享祖先的整条链在旧/新文档中的节点类型、非忽略属性、绝对起始位置必须一致。
越界端点会被静默裁剪,空范围返回空差异;若范围恰好覆盖两个同尺寸文档的完整公共窗口,等价于无范围的全量比对。普通用户不会直接调用带 range 的版本(审阅入口startDiffReviewCmd走全量路径),但 AI 审阅、局部对账等上层功能可据此实现增量比较。
完整 API 参考
Plugin 导出(@milkdown/kit/plugin/diff)
| 导出 | 类型 | 说明 |
|---|---|---|
diff | MilkdownPlugin[] | 插件数组,一次性注册全部 diff 能力 |
diffPlugin | $prose插件 | ProseMirror 差异状态插件(含编辑锁定) |
diffPluginKey | PluginKey<DiffState \| null> | 插件 key,可用于diffPluginKey.getState(state)读取审阅状态 |
diffConfig | $ctx<DiffConfig> | 差异计算配置上下文 |
Commands
| 命令 | 参数 | 说明 |
|---|---|---|
startDiffReviewCmd | (markdown?: string) | 用 Markdown 字符串开启审阅 |
startDiffReviewFromDocCmd | (node?: Node) | 用预解析节点开启审阅 |
acceptDiffChunkCmd | (index?: number) | 按索引接受单条改动 |
rejectDiffChunkCmd | (index?: number) | 按索引拒绝单条改动 |
acceptDiffRangeCmd | (range?: DiffRange) | 按范围接受(自定义块必须用它) |
rejectDiffRangeCmd | (range?: DiffRange) | 按范围拒绝(自定义块必须用它) |
acceptAllDiffsCmd | 无 | 接受全部剩余改动 |
clearDiffReviewCmd | 无 | 清空审阅、解锁编辑器 |
Utilities
computeDocDiff(oldDoc, newDoc, options?):直接计算两份文档间的Change[],options支持ignoreAttrs与range(上文已详述);getPendingChanges(state):返回尚未被拒绝的改动列表(过滤掉与rejectedRanges重叠的项,见 diff-plugin.ts);isChangeRejected(change, rejectedRanges):判断某个改动是否与已拒绝区间重叠(区间交集判定,diff-plugin.ts)。这两个工具在 diff-plugin.spec.ts 测试 中覆盖了包含、相交、相邻、前后等边界情形。
Types
| 类型 | 说明 |
|---|---|
DiffState | 审阅状态:newDoc(目标文档)、changes(当前差异,文档变动时重算)、rejectedRanges(已拒绝区间,基于newDoc稳定坐标)、active(是否审阅中) |
DiffConfig | 插件配置:{ ignoreAttrs } |
DiffRange | 双文档位置范围:{ fromA, toA, fromB, toB } |
DiffAction | 插件可接收的 action 联合类型:start/accept/reject/acceptRange/rejectRange/acceptAll/clear |
ComputeDocDiffOptions | computeDocDiff选项:{ range?, ignoreAttrs? } |
ComputeDiffRange | 对称子区域:{ from?, to? }(省略时默认 0 到内容末尾) |
DiffIgnoreAttrs | 忽略属性映射:Record<string, string[]> |
其中DiffState与DiffAction的完整定义见 types.ts,computeDocDiff的类型定义见 diff-compute.ts。
适用场景与注意点
- 适用场景:文档审阅 / 批注工作流(审阅者对同一篇文档的修改逐条表决)、AI 生成内容的差异预览(Crepe 的 AI 特性即在此列)、版本对账与合并前的差异确认、以及基于
computeDocDiff构建的增量同步或局部比较工具。 - 编辑锁定:审阅一旦开启,编辑器即进入只读态(
filterTransaction拦截所有文档修改事务),务必提供显式的接受/拒绝或clearDiffReviewCmd退出路径,否则用户会"卡"在审阅态。 - 自定义节点视图:凡是自己实现了 node view 的块级节点,都应加入
customBlockTypes,否则差异无法正确渲染。 - 样式依赖:独立使用时必须自行提供
milkdown-diff-*系列样式;Crepe 主题已内置,开箱即用。
【免费下载链接】milkdown🍼 Plugin driven WYSIWYG markdown editor framework.项目地址: https://gitcode.com/GitHub_Trending/mi/milkdown
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考