Plate 引用块自动格式化>回归修复:从 localhost:3000 复现到 createRuleFactory 配置默认值合并
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
导读
本文以 Plate 仓库中一次真实的 bug 修复实战为主线:在localhost:3000上输入>后段落不再自动提升为引用块(blockquote)。从用户复现、逐层排查,最终定位到createRuleFactory构建对象式配置规则时未把配置默认值(如marker: '>')合并进运行时解析器输入这一深层根因,并通过包级单测、core 回归单测与 app 级集成测试三层防线完成闭环。读完本文,你将掌握 Plate 输入规则(input rules)工厂的配置合并机制、引用块作为容器节点时的wrapNodes自动格式化写法,以及"先写失败测试、再修根因、最后浏览器验证"的完整调试方法论。
问题背景:>在 localhost:3000 上不再提升为引用块
用户明确要求在localhost:3000上做真实复现:在段落开头输入>时,期望当前段落被自动包裹成 blockquote,但实际表现是>以纯文本形式留在原地,段落没有被提升。
这一现象与 2026-04-17-blockquote-autoformat-port-3000.md 中记录的排查结论一致:这不是单纯的块类型设置问题,而是横跨 core 输入规则工厂与 app 层 kit 装配两层的系统性缺陷。排查工作的三个关键约束是:
- 必须在真实浏览器环境验证,不能仅凭代码推断下结论;
- 必须先补上缺失的测试覆盖,再动手修复;
- 引用块此前已有一次嵌套自动格式化 bug 的历史教训(见下文),本次排查需要把嵌套场景一并锁定。
前置知识:引用块是"容器",自动格式化必须走 wrapNodes
在分析本次根因之前,必须先理解 Plate 中引用块的特殊性——它是容器节点而非可重标记(retaggable)的扁平块。
此前的 2026-04-02-blockquote-autoformat-must-wrap-nested-quotes.md 已经记录了同样的教训:当 blockquote 从扁平块演变为容器元素后,通用块自动格式化路径假设目标是一个可以改type字段的块,因此在根级仍然"碰巧"能工作(依赖归一化),但在已有引用块内部再输入>时,期望的是嵌套包装(blockquote > blockquote > p),通用路径却只生成了blockquote > p加文本> hello的错误形状。
当时总结的三条"不可行方案"至今仍有参考价值:
| 尝试过的方案 | 为什么不行 |
|---|---|
在packages/autoformat里当作通用自动格式化 bug 修 | 包级块变换对扁平块类型的行为完全符合设计,不是包的问题 |
仅用type: KEYS.blockquote走setNodes | 对包装元素而言,setNodes是错误操作 |
用toggleBlock(..., { wrap: true })处理该接缝 | 已处于引用块内部时,toggle 语义可能"取消包装"而非嵌套 |
最终确认的正确方案是显式声明引用块的包装语义:
{ allowSameTypeAbove: true, // 允许光标已在引用块内部时继续触发 format: (editor) => { editor.tf.wrapNodes({ children: [], type: KEYS.blockquote }); }, match: '> ', mode: 'block', type: KEYS.blockquote, }核心要点有二:
wrapNodes保留容器关系:嵌套引用需要"一个引用块包裹另一个引用块",而不是"一个块改变 type 字段",wrapNodes(...)直接表达这种父子关系;allowSameTypeAbove: true解除同类型守卫:默认情况下规则在光标已经位于同类元素内部时会被拦截,这个开关让根级与嵌套级都能触发。
本次>在 3000 端口的回归,正是在这段历史教训的背景下被排查的:不能想当然认为又是 blockquote 规则本身的问题,需要先验证规则配置是否被正确传递。
排查路径:从包级单测缺口到 app 级集成测试
本次排查首先明确了已有的覆盖边界:
- 包级单测已存在:
packages/basic-nodes/src/lib/BaseBlockquoteInputRules.spec.tsx中已有两条用例——根级>包裹段落为 blockquote,以及已处于引用块内部时嵌套包装为blockquote > blockquote > p,且断言了光标最终落在新引用块内的hello文本开头(selection 为path: [0, 0, 0, 0])。这说明 basic-nodes 包层面的自动格式化行为本身是经过验证的。 - 真正缺失的是 app 级"shipped kit"覆盖:
BasicBlocksKit是apps/www中把BlockquotePlugin、HeadingRules、HorizontalRuleRules等装配起来的真实分发面(见 basic-blocks-kit.tsx),而当时没有任何测试在该 kit 表面上验证>提升行为。
因此本次新增了 app 级集成测试 basic-blocks-kit.slow.tsx,通过createSlateEditor({ plugins: BasicBlocksKit, ... })直接装配完整 kit,模拟>hello文本后插入空格' ',断言:
- 根级场景下
editor.children[0]变为{ children: [{ children: [{ text: 'hello' }], type: 'p' }], type: 'blockquote' }; - 光标被移到新引用块内
hello文本的起点({ offset: 0, path: [0, 0, 0] })。
这条测试先把根级>提升行为锁定在 kit 分发面上,而嵌套场景由包级单测继续守护,形成互补。
真正的根因:createRuleFactory 对象配置未合并默认值
在补上 app 级失败测试后,真正的缺陷浮出水面——它比 blockquote 本身更深。
Plate 的自动格式化规则允许两种定义方式:
- 函数式 builder:
createRuleFactory(configBuilder),builder 接收运行时 options 并返回配置; - 对象式配置:
createRuleFactory(config),直接传入配置对象。
问题出在对象式路径上。createRuleFactory.ts 的实现中:
return (options: Record<string, unknown> = {}) => { const factoryOptions = typeof configOrBuilder === 'function' ? options : { ...(configOrBuilder as Record<string, unknown>), ...options }; // ... const getFactoryInput = <TContext extends object>(context: TContext) => getMergedInput(context, factoryOptions);修复后,factoryOptions会把对象式配置configOrBuilder整体并入({ ...config, ...options }),随后所有resolveFactoryValue与回调(如match、apply、enabled)都能通过getFactoryInput(context)拿到合并后的输入。
而在修复前,对象式配置里的默认字段(比如BlockquoteRules.markdown中定义的marker: '>')没有进入运行时输入,导致({ marker }) => marker这类依赖默认值的回调在真实编辑器流程中解析出undefined,match匹配失败,>自然不会被提升为引用块。
以 BasicBlockRules.ts 中的BlockquoteRules为例,其完整定义如下:
export const BlockquoteRules = { markdown: createRuleFactory<{}, { marker: string }>({ type: 'blockStart', marker: '>', // 配置默认值,曾被吞掉 trigger: ' ', enabled: ({ editor }) => !editor.api.some({ match: { type: [editor.getType(KEYS.codeBlock)], }, }), match: ({ marker }) => marker, // 修复前 marker 为 undefined apply: ({ editor }, match) => { editor.tf.delete({ at: match.range }); editor.tf.wrapNodes( { children: [], type: editor.getType(KEYS.blockquote) }, { match: (node) => editor.api.isBlock(node), } ); return true; }, }), };可以看到marker是作为配置默认值声明的,match回调依赖它。当createRuleFactory没有把默认值合并进运行时输入时,这条规则在编辑器里就会静默失效——包级单测之所以通过,是因为单测环境与真实编辑器流程传入的上下文形态不同,这正好解释了"代码看起来没问题、浏览器里却复现"的割裂感。
修复落地与 core 级回归单测
修复本身收敛在createRuleFactory的输入合并逻辑上,一行核心变更(见 createRuleFactory.ts):对象式配置不再被当作"仅静态配置",而是与运行时options一起合并为factoryOptions,随后所有resolveFactoryValue求值和回调调用都通过getFactoryInput(context)获得完整输入。
配套的 core 回归单测 createRuleFactory.spec.ts 锁定两条契约:
- 默认值必须传入解析器:
createRuleFactory<{}, { marker: string }>({ type: 'blockStart', marker: '>', trigger: ' ', match: ({ marker }) => marker })()在无任何公共 options 的情况下调用rule.resolve(...),断言解析结果为{ range, text: '>' }—— 证明marker默认值生效; - resolveMatch 扩展数据与基础 match 数据合并:
createRuleFactory<{}, {}, { start: number }>使用正则match与自定义resolveMatch,断言最终 match 同时包含基础range/text与扩展的start字段。
这两条测试分别守护"配置默认值注入"与"match 数据合并"两个行为面,防止未来重构再次破坏对象式配置路径。
浏览器验证:在 localhost:3000 上用真实编辑器变换取证
由于/blocks/basic-blocks-demo页面上的原始按键模拟(raw keystroke typing)噪音较大、不够稳定,验证阶段采用了更精准的方案:从页面中拉取 live editor 实例,直接调用真实的编辑器变换(transforms)。
在localhost:3000上依次执行:
insertBreak()—— 新建段落,模拟回车后的起始状态;- 输入
>与后续文本; - 断言编辑器树中产生尾部引用块(trailing blockquote)。
该验证路径证明:修复后的规则在真实编辑器运行时上下文中,marker默认值能正确注入解析器,match命中后apply中的wrapNodes按预期把段落包装为引用块。这也再次印证了复盘结论——仅靠包级单测无法覆盖真实编辑器的上下文形态,浏览器级的 live-editor 验证是此类输入规则回归的必要手段。
三层回归防线与防复发建议
本次修复最终形成如下三层防线:
| 层级 | 测试文件 | 守护内容 |
|---|---|---|
| 包级单测 | BaseBlockquoteInputRules.spec.tsx | 根级与嵌套级>提升、光标落点 |
| core 级单测 | createRuleFactory.spec.ts | 配置默认值注入、match 数据合并 |
| app 级集成测试 | basic-blocks-kit.slow.tsx | BasicBlocksKit真实分发面上的>提升 |
结合本次实战与历史教训,可沉淀出以下防复发经验:
- 对象式
createRuleFactory配置的默认值必须进入运行时输入。凡是配置里声明了marker、variant等字段并在match/apply/enabled回调中消费的规则,都应有一条"无公共 options 调用"的 core 单测兜底; - 容器类节点的自动格式化要独立审计。引用块是包装元素,根级
setNodes路径失效不代表规则逻辑错误,要检查是否应走wrapNodes并配合allowSameTypeAbove; - 包级单测 ≠ app 级可用性。
BasicBlocksKit这类 shipped kit 的装配面需要专门的集成测试,防止包内正确、装配后失效的断层; - 浏览器验证优先使用 live editor 实例与真实 transforms。原始按键模拟受 IME、浏览器事件时序影响,噪音大;直接调用编辑器变换能更稳定地验证修复路径。
延伸阅读
- 引用块容器化后的首次嵌套自动格式化修复:2026-04-02-blockquote-autoformat-must-wrap-nested-quotes.md
- 输入规则最佳实践——围栏匹配与功能应用分离:block-fence-input-rules-should-split-fence-matching-from-feature-apply.md
- 输入规则最佳实践——显式注册规则实例与包导出 markdown 家族:input-rules-should-register-explicit-rule-instances-while-packages-export-markdown-families.md
- 引用块插件本体(归一化、break/delete 提升、Tab 缩进/反缩进):BaseBlockquotePlugin.ts
- 输入规则工厂类型定义与实现:createRuleFactory.ts
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考