Plate 节点模型与亲和性(Node Model & Affinity)规范:从文档修复到运行时落地的完整实践
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
导读
本文基于 Plate 仓库中 editor-behavior 文档体系的一次专项规范整理(2026-04-04-node-model-affinity-spec-pass.md),系统讲解 Plate 如何把"节点原子性(atomicity)、voidness 与亲和性(affinity)"从模糊的文字描述升级为每类功能家族必须显式声明的规范字段。你将掌握:Plate 的 7 类节点模型与 4 类亲和性分类体系、文档与运行时源码交叉审计的方法、以及一次真实的"规范先说、实现后追"案例(footnote 引用从非 void 文本变为真正的 inline void atom)。文章同时结合 markdown-standards.md、markdown-editing-spec.md、editor-protocol-matrix.md 与packages/*下的插件源码,给出可在当前仓库中逐条验证的实践路径。
一、这次 Spec Pass 要解决什么问题
1.1 背景:文档在"手挥"编辑器模型
在本次专项之前,Plate 的 editor-behavior 文档已经在谈论"atoms(原子)"和 affinity,但没有建立强制约束:每个功能家族都必须声明一个显式的模型字段。结果就是规范文档在真正关键的地方"手挥"(hand-waving)——用自然语言描述节点的原子性,却不落到可测试、可交叉验证的模型声明上。
1.2 Goal 原文
本次 pass 的目标(Goal)非常明确:
Make node atomicity, voidness, and affinity explicit across the editor-behavior docs so the spec stops hand-waving over the actual editor model.
即:让节点原子性、voidness 与亲和性在全部 editor-behavior 文档中显式化,使规范不再回避真实编辑器模型。
1.3 六阶段执行计划
原文档把工作拆成六个阶段(Phase),全部标记为已完成:
| 阶段 | 内容 | 交付物 |
|---|---|---|
| 1 | 复查当前规范在 footnote 引用、inline atoms 与 affinity 策略上的缺口 | 缺口清单 |
| 2 | 交叉检查当前各包表面真实的isVoid、isInline、mark-affinity 声明 | 运行时表面审计 |
| 3 | 更新 standards 文档,要求显式的节点模型与 affinity 声明 | markdown-standards.md |
| 4 | 更新 readable spec,补充节点模型分类与修正后的家族法则 | markdown-editing-spec.md |
| 5 | 更新 parity/protocol 文档,使每个功能家族都声明自己的模型与 affinity 类 | markdown-parity-matrix.md、editor-protocol-matrix.md |
| 6 | 验证文档一致性,记录新暴露的过时声明 | 一致性核查结果 |
这套"先审计、后改文档、再验证"的顺序本身就是值得复用的方法:规范改动不是拍脑袋,而是先对源码做地毯式扫描,再反向约束文档。
二、核心成果一:节点模型(Node Model)七分类
2.1 规范定义
standards 文档(markdown-standards.md)与 readable spec(markdown-editing-spec.md)中,节点模型被统一为以下 7 类:
| 模型类 | 含义 | 典型实体 |
|---|---|---|
block non-void | 可编辑的块级/容器内容 | 段落、标题、块引用、列表项、代码块、表格、脚注定义 |
block void atom | 富文本模式下内部无光标的原子块表面 | TOC、分隔线(thematic break)、媒体嵌入、图片、绘图、块级公式 |
inline non-void span | 可编辑的内联内容,如链接 | 链接、autolink 字面量 |
inline void atom | 无富文本正文的原子内联表面 | mention、date、脚注引用、内联公式 |
leaf mark | 由 leaf 携带的文本标记,而非独立内联元素 | 加粗、斜体、删除线、高亮、上/下标、样式类 mark |
text token | 保留语法的文本行为,如解析后的硬换行 | 硬换行、emoji 短代码解析后的文本 |
overlay / no node | 不拥有文档节点的编辑器 chrome | 生成的 TOC 条目、Yjs 远程光标、讨论锚点 |
2.2 四条强制规则
规范同步立下了四条不允许绕过的规则:
- 不要从 UI chrome 推断原子性——界面上看起来像一个整体,不代表节点是原子的;
- 不要从 DOM 的
contentEditable={false}推断 voidness——渲染层的小技巧不能替代编辑器节点契约; - 必须使用编辑器节点契约(editor node contract),而非渲染 DOM 技巧;
- 如果一个特性是非 void 且参与内联输入,规范必须声明它使用哪一类 affinity;而 inline void atoms 不依赖 link/mark affinity,它们作为原子自己拥有导航与边界删除行为。
这套规则直接堵死了"实现与文档各说各话"的漏洞,也为此后所有功能家族建立了统一的语言。
三、核心成果二:Affinity(亲和性)四分类
3.1 分类定义
当内联输入可能跨越某个边界时,规范要求声明亲和性类:
| Affinity 类 | 行为 | 适用对象 |
|---|---|---|
directional | 从已格式化一侧输入会扩展该格式;从纯文本一侧输入则保持在格式之外 | 加粗/斜体等软 mark、链接 span |
hard | 边界输入保持在格式之外,不扩展格式化 span | 内联代码、kbd 等偏源码的内联节点 |
outward | 元数据范围偏向避免意外增长 | 评论(comment)、建议(suggestion)等协作元数据 mark |
none / n-a | 不拥有内联亲和性 | 块级节点、void atoms、text tokens、overlays |
3.2 为什么需要 affinity
readable spec 中有一句非常关键的话(markdown-editing-spec.md):
Affinity belongs here because cursor behavior changes the meaning of later typing and deletion.
光标停留在边界时的行为会改变后续输入与删除的语义。典型规则见EDIT-AFF-MARK-001(**bold|**text输入x后得到**boldx**text,即 directional)、EDIT-AFF-LINK-001(链接侧进入扩展链接,纯文本侧进入则留在外面)、EDIT-AFF-HARD-001(`code|`text输入后得到`code`xtext,即 hard)。
3.3 源码中的 affinity 实现
在 packages/core/src/lib/plugins/affinity/AffinityPlugin.ts 中,affinity 被实现为一个独立的 core 插件:
affinity值为'backward' | 'forward',用于描述删除操作后光标应该贴向哪一侧;- 删除跨过 mark 边界时,若删除的是右侧字符则 affinity 为
forward,删除的是左侧 mark 字符则为backward; - 插件通过
rules.selection?.affinity读取每个节点类型声明的亲和性(AffinityPlugin.ts)。
而isNodeAffinity查询(packages/core/src/lib/plugins/affinity/queries/isNodeAffinity.ts)把规范中的三类值'directional' | 'hard' | 'outward'与插件规则直接对接,形成了"文档分类 ↔ 源码类型"的一一映射。
四、运行时表面审计:每类功能家族的模型归属
本次 pass 最重要的发现之一是:运行时表面是混合的(Runtime surfaces are mixed)。原文档 Findings 明确列出了当时的真实状态:
- links:
inline non-void span+directionalaffinity; - mention / date / inline equation:
inline void atom; - TOC、thematic break、media embed、file/audio/video、image、drawing、block equation:
block void atom; - 许多格式化 marks 已经在插件规则中暴露 selection affinity(
directional、hard或outward)。
protocol matrix 中有一张"实体模型映射表"(Entity Model Map),是这次审计沉淀下来的权威清单(editor-protocol-matrix.md),摘录关键行:
| 家族 | 实体 | 节点模型 | 亲和性/边界策略 |
|---|---|---|---|
| markdown-native | 段落 / 标题 / 块引用 / 列表项 | block non-void | n/a |
| markdown-native | 链接 | inline non-void span | directional |
| markdown-native | 图片 | block void media atom | n/a |
| markdown-native | 软 mark / 硬 mark | leaf mark | directional/hard |
| markdown-native | 代码块 | block non-void owner | n/a |
| markdown-native | 分隔线 | block void atom | n/a |
| markdown-extension | 内联公式 / 块级公式 | inline void atom / block void atom | n/a |
| markdown-extension | autolink 字面量 | inline non-void link span | directional |
| markdown-extension | 脚注引用 / 脚注定义 | inline void atom / block non-void container | n/a |
| block-editor-native | mention / date | inline void atom | n/a |
| block-editor-native | callout / toggle / 列 | block non-void container | n/a |
| block-editor-native | TOC / 媒体嵌入 / code drawing / excalidraw | block void atom | n/a |
| collaboration | comment / suggestion | leaf metadata mark | outward |
| collaboration | discussion / Yjs 光标 | overlay / no node | n/a |
这张表的意义在于:任何新增功能家族都必须在这张表中占一行,否则协议层不承认它"有模型"。
4.1 源码交叉验证
用插件源码验证上述表格:
- 链接是
inline non-void span:在 packages/link/src/lib/BaseLinkPlugin.ts 中只声明了isInline: true,没有isVoid——这正是"内联但可编辑"的节点契约; - mention 是
inline void atom:在 packages/mention/src/lib/BaseMentionPlugin.ts 中声明为node: { isElement: true, isInline: true, isVoid: true },三者齐备; - 脚注引用修复后同样变为
isInline: true, isVoid: true(见下一节)。
这印证了 standards 文档的规则:voidness 由编辑器节点契约决定,而不是由渲染 DOM 决定。
五、案例研究:footnote 引用的"规范谎言"与运行时修复
5.1 发现的问题
原文档 Notes 部分记录了一个非常典型的问题:
The spec drifted into a real lie on footnotes:
footnoteReferencewas treated like an atom in prose while the runtime node was still non-void.
即:文档散文把footnoteReference当作 atom 来描述,但运行时节点仍然是非 void 的。规范与实现出现了真实的分叉。这次 pass 最初只是"纯文档工作"(docs-only),却在审计中暴露了这个真实的运行时 mismatch,从而触发了后续的执行修复(2026-04-04-footnote-inline-void-fix.md)。
5.2 修复目标与症状
修复文档明确列出了要消灭的浏览器症状:
- 引用后的 Backspace 会编辑可见的标识符(
[^1]中的数字被当作可编辑文本); - 回链(backlink)导航触发的是通用编辑 chrome,而不是干净的导航。
5.3 修复结果
修复完成后:
footnoteReference成为带空子节点哨兵(empty child sentinel)的 inline void atom——在 packages/footnote/src/lib/BaseFootnoteReferencePlugin.ts 中可以看到最终声明:node: { isElement: true, isInline: true, isVoid: true },且render: { as: 'sup' }渲染为上标;- 回链聚焦落在引用旁最近的稳定兄弟文本点(nearest stable sibling text point),而不是节点范围选择;
- 浏览器验证(
/docs/footnote页面)显示:回链跳转落在[1]后的.上、屏幕上只有一个 toolbar、一次 Backspace 删除整个引用而不是单个数字。
配套的测试也同步固化:在 packages/footnote/src/lib/BaseFootnotePlugins.spec.ts 中同时断言了isInline: true, isVoid: true的模型声明以及非 void 场景的反例。
5.4 这一案例的方法论价值
这是"规范驱动实现"(spec-driven implementation)的教科书案例:
- 规范先立规矩(footnote 引用 = inline void atom);
- 审计发现运行时不符合(仍是非 void 文本);
- 用红色测试锁定期望行为;
- 实现修复并把模型声明改到与规范一致;
- 浏览器级验证收尾。
它同时说明:文档审计不是文案工作,它能反向暴露真实的产品 bug。
六、文档体系中的落地与关联
6.1 规范的三层结构
本次 pass 更新了三个层面的文档,它们分工不同:
| 文档 | 角色 | 关键内容 |
|---|---|---|
| markdown-standards.md | 方法论与权威模型 | 参考池(Typora/Obsidian/Notion/Google Docs/GitHub/Milkdown)、权威顺序、节点模型与 affinity 要求、偏差政策、Spec ID 方案 |
| markdown-editing-spec.md | 规范性家族法则(readable law) | 全局不变量、节点模型与 affinity 类、所有权顺序、每个家族的EDIT-*规则 |
| editor-protocol-matrix.md | 穷尽式场景矩阵 | Row Schema、实体模型映射表、按家族的协议行、状态机(seeded/specified/tested/partial/deferred) |
| markdown-parity-matrix.md | 发布门槛 | 按家族的语法支持与 round-trip 状态 |
protocol matrix 的 "Practical Use" 一节给出了工作流闭环:
先在 protocol matrix 中加穷尽场景行 → 在 markdown-editing-spec.md 中锁定行为 → 在 markdown-parity-matrix.md 中跟踪家族级充分性。
6.2 Spec ID 与测试映射
standards 文档还确立了稳定的 Spec ID 方案:EDIT(编辑行为)、PARITY(解析/序列化/往返)、STREAM(流式 markdown)、DEV(有意偏差)。目标是从文档直接驱动 TDD——每个锁定的规则都应映射到测试、所属包与行为 profile。协议矩阵中大量行已经标注了tested状态及对应的证据文件(如AffinityPlugin.spec.tsx、withBreakRules.spec.tsx、withTable.spec.tsx等)。
七、实践建议与延伸阅读
7.1 如果你想为 Plate 新增一个功能家族
按本次 pass 沉淀的流程走:
- 在 editor-protocol-matrix.md 的实体模型映射表中为新实体占一行,声明节点模型与 affinity 类;
- 在 markdown-editing-spec.md 中为该家族补充
EDIT-*规则与 canonical 示例; - 在插件源码的
node配置中如实声明isElement/isInline/isVoid(参考 BaseMentionPlugin.ts 或 BaseFootnoteReferencePlugin.ts); - 为边界行为编写 red tests(reference 现有
AffinityPlugin.spec.tsx等测试); - 在 markdown-parity-matrix.md 中更新家族级覆盖状态。
7.2 三条可复用的核心经验
- 文档必须显式声明模型:任何功能家族如果没有声明节点模型与 affinity 类,就视为"未规范";
- 以编辑器节点契约为准,不以渲染 DOM 为准:
contentEditable={false}不是 void 的证明; - 文档审计是 bug 探测器:规范与实现不一致的地方,往往是真实产品缺陷(footnote 引用就是证据)。
如需继续深入,建议顺序阅读:markdown-standards.md(方法论)→ markdown-editing-spec.md(家族法则)→ editor-protocol-matrix.md(场景矩阵)→ 2026-04-04-footnote-inline-void-fix.md(修复案例),并结合packages/core/src/lib/plugins/affinity/下的实现逐条对照。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考