完整指南)
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 插件分为两部分负责差异计算与审阅状态的pluginmilkdown/kit/plugin/diff以及负责差异可视化渲染的componentmilkdown/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配置上下文、diffPluginProseMirror 插件以及 8 个命令插件而 components/src/diff/index.ts 中的diffComponent则由diffComponentConfig与diffDecorationPlugin组成。组件负责把 plugin 计算出的Change列表翻译成 ProseMirror 的 inline/widget decorations 并渲染 Accept/Reject 控件。在 Crepe 中使用如果你使用的是 Crepemilkdown 的开箱即用编辑器外壳无需手动注册 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的差异生成一个DiffStateactive: 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.tsacceptDiffChunkCmd(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)按DiffRangefromA/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 配置diffConfigdiffConfig控制差异计算的规则目前唯一配置项是ignoreAttrsimport { 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: Recordstring, string[]一个字段含义是按节点类型名映射到需要忽略的属性键数组。默认值{ heading: [id] }——即默认忽略标题节点的id属性防止自动生成的 id 干扰差异计算见 diff-config.ts。作用位置该配置在每次computeDocDiff调用时传入由 token encoder 消费。ignoreAttrs在节点开始 token 的编码中生效被忽略的属性不参与 token 生成因此两个仅在忽略属性上不同的节点会被视为相同见 diff-compute.ts。值得注意ignoreAttrs还贯穿差异的递归与范围切分——祖先链一致性校验、子节点配对都复用 encoder 的 node-start token因此忽略规则在块级配对、range模式的结构校验中同样生效。Component 配置diffComponentConfigdiffComponentConfig控制差异的可视化渲染支持三个配置项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字段类型默认值说明acceptLabelstringAccept接受按钮的文本rejectLabelstringReject拒绝按钮的文本customBlockTypesstring[][]需要块级整体替换渲染的节点类型名列表所有 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 组件的核心渲染逻辑在buildDecorationsdiff-decoration-plugin.ts它会先对 pending 改动做块级合并mergeBlockChanges再逐条决策渲染方式纯文本级改动渲染为inline装饰——删除用删除线插入用行内 widget跨块边界改动拆分为 inline block 两段分别渲染同时保证整组差异只有一组控件删除仅覆盖尾部空段落Crepe 等编辑器总是保留末尾空段时直接跳过避免视觉噪音块级 widget 的位置会通过snapToBlockBoundary吸附到块边界控件与新增内容对齐展示。样式Stylingdiff 组件输出的是带语义类名的 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生成一个TokenEncoderstring | 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-blockLCS策略为每个容器节点的子节点计算结构签名递归走一遍 token encoder用 LCS 动态规划在旧/新子节点列表之间找最长公共子序列lcsMatchO(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导出类型说明diffMilkdownPlugin[]插件数组一次性注册全部 diff 能力diffPlugin$prose插件ProseMirror 差异状态插件含编辑锁定diffPluginKeyPluginKeyDiffState \| null插件 key可用于diffPluginKey.getState(state)读取审阅状态diffConfig$ctxDiffConfig差异计算配置上下文Commands命令参数说明startDiffReviewCmd(markdown?: string)用 Markdown 字符串开启审阅startDiffReviewFromDocCmd(node?: Node)用预解析节点开启审阅acceptDiffChunkCmd(index?: number)按索引接受单条改动rejectDiffChunkCmd(index?: number)按索引拒绝单条改动acceptDiffRangeCmd(range?: DiffRange)按范围接受自定义块必须用它rejectDiffRangeCmd(range?: DiffRange)按范围拒绝自定义块必须用它acceptAllDiffsCmd无接受全部剩余改动clearDiffReviewCmd无清空审阅、解锁编辑器UtilitiescomputeDocDiff(oldDoc, newDoc, options?)直接计算两份文档间的Change[]options支持ignoreAttrs与range上文已详述getPendingChanges(state)返回尚未被拒绝的改动列表过滤掉与rejectedRanges重叠的项见 diff-plugin.tsisChangeRejected(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/clearComputeDocDiffOptionscomputeDocDiff选项{ range?, ignoreAttrs? }ComputeDiffRange对称子区域{ from?, to? }省略时默认 0 到内容末尾DiffIgnoreAttrs忽略属性映射Recordstring, string[]其中DiffState与DiffAction的完整定义见 types.tscomputeDocDiff的类型定义见 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),仅供参考