Plate 导航反馈契约(Navigation Feedback)设计规范:从 TOC、脚注到搜索跳转的统一编辑体验
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
导读
本文围绕 Plate 富文本编辑器项目中的共享「导航反馈」规范展开,系统讲解docs/plans/2026-04-06-navigation-feedback-spec.md所定义的跨界面编辑定律:任何成功的导航跳转都应移动焦点/光标、将目标滚动进视口、并对落点目标做短暂高亮。这份规范的目标是把当前 TOC(目录)、脚注导航、搜索跳转等功能各自重复实现的"闪烁/滚动/选中修复"逻辑,收敛为一个编辑器级共享原语。读完本文,你将掌握该契约的完整 API 设计(flashTarget/navigate)、目标模型、分层架构(lib 层 + React 层)、分阶段落地计划与测试矩阵,并能结合仓库源码看清它在packages/core、packages/footnote、packages/toc中的实际落地形态。
本文为文档解读 + 源码印证文章,文中引用的文件路径均以仓库根目录为基准。
1. 为什么需要一个共享导航反馈契约
1.1 现状:每个跳转功能都在"各写各的"
在 Plate 的编辑器行为体系中,以下场景都需要在跳转后给出视觉反馈:
- TOC 点击跳转:点击目录条目后滚动到对应标题;
- 脚注导航:从参考文献跳转到定义、或从定义跳回引用处;
- 搜索跳转:跳转到搜索命中的文本位置(本规范中属于延迟项)。
每个功能实现"跳转反馈"时,都需要反复处理同一组逻辑:
- 目标解析与交接(target resolution handoff);
- 瞬时高亮的计时(transient highlight timing);
- 上一次目标状态的新旧替换与清理(replacement/clearing of prior target state);
- 视觉 token 的逐步漂移(visual-token drift)。
正如契约文档 docs/plans/2026-04-06-navigation-feedback-contract.md 指出的:没有共享契约,就会出现重复的定时器、重复的状态、重复的渲染逻辑,长期维护与 DX 体验都是最差的。
1.2 已有律法:三条"编辑器定律"
规范中明确,导航反馈的三条规则已经在多份文档中作为跨界面定律存在:
- 成功的导航应让焦点/光标落在目标处(move focus or caret);
- 将目标滚动进视口(scroll target into view);
- 短暂高亮落点目标(briefly highlight the landed target)。
这些规则分布在:
- markdown-editing-spec.md(其中
EDIT-NAV-FEEDBACK-*已标记为locked,即该行为定律已被锁定); - editor-protocol-matrix.md(协议矩阵中为 footnote definition / footnote reference / search target / heading target 四类场景各登记了一行跨界面导航反馈协议);
- editor-behavior-architecture.md(架构文档将导航反馈作为共享编辑器领域对待)。
规范的核心判断是:定律已经存在,但缺失的是共享运行时契约(shared runtime contract)。这正是本规范要填补的空缺。
1.3 两个真实消费者的差异:不能强行抽象成同一个原语
契约文档特别强调:当前两个消费者"相关但不同质":
- 脚注导航是"选中驱动"的:它拥有具体的光标/选区点,属于 selection-driven caret movement + focus/scroll;
- TOC 点击是"DOM 滚动优先"的:先滚动,之后才附加块级选中装饰(block-selection chrome),属于 DOM-scroll-first。
因此契约应当标准化两者的交集(目标替换、自动清理、节点高亮语义),而不是假装两个流程今天已经是同一个原语。设计原则第一条即:"只标准化当前消费者已经挣得的交集(overlap)"。
2. 架构决策:契约放哪、怎么分层
2.1 ADR:永久契约落在@platejs/core
契约文档给出了三个候选方案并逐一评估:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
Option A:@platejs/core共享导航插件 | 跨界面契约的天然永久归宿;与现有共享插件、DOM、node-prop 缝对齐;API 对人类与 Agent 最可发现 | 需触碰 core 插件架构;过早过度泛化会抬高回退成本 | ✅推荐 |
Option B:@platejs/selection作为主宿主 | 已拥有选区邻近 UI 与 overlay 面 | 语义上错误(导航不是选区功能);会把廉价节点高亮伪装成选区特性;可能把 overlay 假设拖入基础契约 | ❌ |
Option C:toc/footnote/search各自本地实现 | 每个功能本地 diff 最小 | 必然漂移;重复定时器/状态/渲染逻辑;长期 DX 与维护最差 | ❌ |
ADR 的最终结论是:在packages/core中实现共享导航插件表面(shared plugin surface),包含共享 transforms 与共享渲染状态注入。决策驱动因素为:跨界面复用、低渲染成本、低概念成本、可预测的归属、面向更广目标类型的未来路径。selection将来可以作为可选的 range/overlay 适配器宿主,但不是导航契约的主要归属。
文档还明确排除了把契约放在
floating(浮动层)或新建独立导航包,理由是契约"不属于单一功能族、本质上不是 overlay 几何、不单纯是选区问题、且被多个当前与未来界面所需"。
2.2 两层形态:lib 层管契约,React 层只做适配
规范与契约文档共同强调:不能让 React store 悄悄成为真正的契约。因此采用两层结构:
- Lib 层:在
packages/core内新增一个编辑器作用域的 lib 插件,拥有当前导航目标(current nav target)、请求/脉冲 id(nav request id / pulse id)、前一目标替换(replacement)、自动清理定时器(auto-clear timer); - React 层:只提供把活跃导航目标暴露给渲染器与 hooks 所需的薄适配表面。
约束条件:如果插件/编辑器状态可以干净地驱动inject.nodeProps与渲染 hooks,就不要默认新建独立的NavigationFeedbackStore;只有真实渲染器约束逼不得已时才引入专用 store。
硬性要求:必须证明导航目标变化能触发渲染更新,使inject.nodeProps无需依赖无关的选区变化即可增删高亮属性。
2.3 两类一等消费者模式
Phase 1 明确支持两种模式,且不允许为了让抽象看起来干净而把 TOC 强扭成脚注的形状:
- Selection-driven navigate(选中驱动导航):面向脚注跳转这类拥有具体 caret/selection 点的消费者;
- Flash-only target feedback(仅闪烁目标反馈):面向 TOC 这类应保留当前"非文本选区"导航行为、但复用共享闪烁计时与替换语义的消费者。
3. 渲染策略:node 属性优先,overlay 是后路
3.1 为什么选择>editor.tf.navigation.flashTarget({ target: { type: "node", path }, variant: "navigated", });editor.tf.navigation.navigate({ target: { type: "node", path }, flash: { variant: "navigated" }, focus: true, scroll: true, select: { anchor: point, focus: point, }, });
editor.tf.navigation.navigate({ target: { type: "node", path }, flash: { variant: "navigated" }, focus: true, scroll: true, select: { anchor: point, focus: point, }, });设计意图非常明确:
flashTarget(...)是一等公民,不是 fallback 辅助函数;navigate(...)面向选中驱动流程,负责把 select、focus、scroll、flash 协调在一起;- 不要求每个消费者都必须提供选区语义。
4.2 初始目标类型(target kinds)
- Phase 1A 支持:
node目标(按 Slate path 定位); - 可选但延迟:
block-id、range、自定义 DOM rect 或虚拟目标; - 明确不加:不在早期引入更通用的目标代数(target algebra),除非确实出现需要它的消费者。
这意味着search 被延迟:要么团队有意将range提升进目标模型,要么证明真实搜索跳转只需要 node 目标语义。
5. 精确文件落点与分阶段路线
5.1 Core 包内文件清单
Lib 插件巷(packages/core/src/lib/plugins/):
packages/core/src/lib/plugins/navigation-feedback/NavigationFeedbackPlugin.tspackages/core/src/lib/plugins/navigation-feedback/index.tspackages/core/src/lib/plugins/navigation-feedback/types.tspackages/core/src/lib/plugins/navigation-feedback/transforms/flashTarget.tspackages/core/src/lib/plugins/navigation-feedback/transforms/navigate.tspackages/core/src/lib/plugins/navigation-feedback/transforms/index.ts
并接入packages/core/src/lib/plugins/index.ts与packages/core/src/lib/plugins/getCorePlugins.ts。
React 侧巷(packages/core/src/react/plugins/):
packages/core/src/react/plugins/navigation-feedback/NavigationFeedbackPlugin.tspackages/core/src/react/plugins/navigation-feedback/useNavigationFeedback.ts(仓库最终落地名为useNavigationHighlight.ts,见第 6 节)packages/core/src/react/plugins/navigation-feedback/index.ts
并接入packages/core/src/react/plugins/index.ts与packages/core/src/react/editor/getPlateCorePlugins.ts。
若 node-prop 注入需要可复用 core 帮助函数,优先复用既有注入缝:
packages/core/src/internal/plugin/pipeInjectNodeProps.tsxpackages/core/src/internal/plugin/pluginInjectNodeProps.ts
若滚动集成需要共享选项表面,可参考packages/core/src/lib/plugins/dom/DOMPlugin.ts。
默认分工:lib 插件拥有 transforms 与规范契约;React 层只拥有渲染面适配器/hook 表面;除非渲染管线证明必要,否则不让 React store 成为契约本身。
5.2 功能包集成点
- 脚注:
packages/footnote/src/lib/transforms/focusFootnoteDefinition.ts、packages/footnote/src/lib/transforms/focusFootnoteReference.ts; - TOC:
packages/toc/src/react/hooks/useTocElement.ts(仓库实际实现位于useContentController.ts,见第 6 节); - 搜索:延迟,直到 range-vs-node 目标语义明确。
高亮样式初期应留在应用/编辑器 UI(如apps/www/src/app/globals.css),不要为了发布包级样式而阻塞 core 插件设计。
5.3 分阶段路线图
| 阶段 | 内容 | 关键约束 |
|---|---|---|
| Phase 1A | core 契约 + 选中驱动消费者:lib 插件、React 插件/hook、flashTarget/navigatetransforms、data-nav-target/data-nav-highlightnode-prop 注入、定时器替换/自动清理;集成脚注 ref→def 与 def→ref | 目标类型仅node;先在选中驱动消费者上验证契约;不把 search 拖入本阶段 |
| Phase 1B | 非选中消费者:TOC 作为 flash-first 消费者接入同一契约 | 保留 TOC 现有滚动行为;复用共享闪烁计时/替换语义;不强制文本选区;"TOC 也应落光标"作为独立 UX 决策,不偷偷塞进基础契约 |
| Phase 2 | 显式目标模型扩展:决定 search 需要range还是仅node;如需range,按真正的扩展设计 | 仅当 1A/1B 稳定后才做 |
| Phase 3 | 加固:统一 variant 命名与超时策略;确保替换语义确定性;增加可见目标反馈的浏览器测试 | — |
| Phase 4 | 延迟扩展:range 目标、selection中的 overlay 适配器、讨论/评论锚点消费者 | 仅按需 |
5.4 测试计划
单元 / 包测试(core):
- 插件在
getPlateCorePlugins中注册; flashTarget设置目标状态;- 新 flash 替换旧状态;
- 自动清理定时器清理目标;
navigate按顺序执行 selection + scroll + flash;- node-prop 注入正确增删预期 data 属性;
- 渲染失效路径无需选区变化即可更新高亮属性。
功能消费者:
- 脚注 transforms 调用共享导航 API 而非本地持有 flash;
- TOC 点击路径复用共享闪烁计时/替换语义且不强制选区语义。
集成测试:从一个目标导航到另一个目标时干净地交换高亮;内联 void 目标高亮可用;块级目标高亮可用。
浏览器验证:脚注 ref→def、def→ref 可见地闪烁目标;TOC 跳转可见地闪烁目标;搜索跳转验证延迟。
5.5 风险与缓解
| 风险 | 缓解 |
|---|---|
| 过早的过度泛化抽象 | 只从已挣得的交集起步:flashTarget+ 选中驱动navigate,目标仅node |
| 渲染层耦合 | 保持样式薄、基于 data 属性 |
| core 表面膨胀 | 先只暴露最小的flashTarget/navigate |
| TOC 与脚注并非同一原语 | 在契约中显式声明两种消费者模式 |
| 功能包仍做本地闪烁 | 显式迁移首批消费者并删除本地高亮逻辑 |
| 目标状态更新无法干净触发节点树重渲染 | Phase 1A 中证明渲染失效缝;仅在插件/编辑器状态 + hooks 无法重绘属性时才引入最小 React store |
6. 源码印证:契约在仓库中的实际落地形态
本规范的后续计划文档 docs/plans/2026-04-10-navigation-feedback-path-ref-runtime.md 显示,导航反馈在 core 中以运行时PathRef状态落地。当前仓库中该契约已经实现,且文件布局与规范中的"精确文件落点"基本一一对应。
6.1 Lib 层:packages/core/src/lib/plugins/navigation-feedback/
仓库实际文件为:
NavigationFeedbackPlugin.tstypes.tstransforms/flashTarget.tstransforms/navigate.tstransforms/index.tsNavigationFeedbackPlugin.spec.ts
类型定义(types.ts)中可以看到完整的运行时模型:
export type NavigationFeedbackTarget = { path: Path; type: 'node'; }; export type NavigationFeedbackActiveTarget = NavigationFeedbackTarget & { cycle: 0 | 1; duration: number; pulse: number; variant: string; }; export type NavigationFeedbackStoredTarget = Omit< NavigationFeedbackActiveTarget, 'path' > & { pathRef: PathRef; };值得注意的设计细节:
- 存储态用
PathRef而非裸Path:目标在文档编辑(插入/删除)后仍能保持位置有效性,这是与规范中"替换/清理前一目标状态"直接对应的实现机制; cycle: 0 | 1与pulse脉冲计数:每次 flash 递增 pulse,cycle = pulse % 2,用于驱动 CSS 动画的交替触发(同一节点连续点击两次也能重新播放动画);duration默认 1600ms:NavigationFeedbackPlugin.ts中options.duration: 1600,flashTarget内 fallback 为 800ms。
插件通过extendEditorApi暴露查询面、通过extendEditorTransforms暴露命令面:
// api 面(查询) editor.api.navigation.activeTarget() editor.api.navigation.clear() editor.api.navigation.isTarget(path) // transforms 面(命令) editor.tf.navigation.clear() editor.tf.navigation.flashTarget(options) editor.tf.navigation.navigate(options)flashTarget的实现要点(transforms/flashTarget.ts):
- 使用
WeakMap<SlateEditor, timeout>与WeakMap<SlateEditor, pulse>保存每编辑器的定时器与脉冲计数,避免污染编辑器对象; - 新 flash 会先
clearNavigationTimeout+ 清理上一目标的 DOM 属性与pathRef,保证确定性替换; - 通过
editor.api.toDOMNode(node)拿到目标 DOM 元素,直接设置四个属性:data-nav-target="true"data-nav-highlight={variant}(默认navigated)data-nav-cycle={0|1}data-nav-pulse={n}- 以及 CSS 变量
--plate-nav-feedback-duration: {duration}ms;
- 超时后调用
clearNavigationFeedbackTarget(editor, pulse),带 pulse 校验——只有脉冲匹配时才清理,避免后发的 flash 被先发的定时器误清。
navigate的实现要点(transforms/navigate.ts):
- 按顺序执行:
select(若提供 point/range)→focus→scrollIntoView(滚动目标点优先级为scrollTarget>select.focus>select.anchor>selectpoint >editor.api.start(target.path))→flashTarget(除非flash: false); - 这正是契约文档中"navigate 把 select、focus、scroll、flash 协调在一起"的落地。
6.2 React 层:packages/core/src/react/plugins/navigation-feedback/
仓库实际文件为:
NavigationFeedbackPlugin.tsuseNavigationHighlight.tsNavigationFeedbackPlugin.spec.tsxindex.ts
(规范草案中的 hook 名为useNavigationFeedback.ts,最终落地为useNavigationHighlight.ts,这是文档与实现的唯一命名差异。)该 hook 供渲染器消费活跃导航目标,配合 node-prop 注入缝完成高亮属性的渲染面适配。
6.3 脚注消费者:选中驱动 navigate
在packages/footnote/src/lib/transforms/focusFootnoteDefinition.ts与focusFootnoteReference.ts中,脚注跳转已改为调用共享 API:
return editor.tf.navigation.navigate({ ... });测试 insertFootnote.spec.ts 中也可以看到对editor.api.navigation.activeTarget()的断言(如 L307、L324、L363),验证了跳转后活跃目标状态确实被设置。
6.4 TOC 消费者:flash-first
在 useContentController.ts 中,TOC 点击路径通过editor.tf.navigation.flashTarget(...)复用共享闪烁语义(见 L74 附近),且不强制文本选区——正是规范 Phase 1B 所要求的"保留 TOC 当前滚动行为、复用共享闪烁计时/替换语义、不强制选区"。
调试记录 2026-04-07-debug-toc-demo-nav-progress.md 证实了浏览器端的实际表现:在/blocks/toc-demo点击Benefits of Using TOC后,产生了一行aria-current="location"的目录条目,以及一个data-nav-target="true"的标题高亮。
6.5 搜索:仍在延迟清单
搜索跳转的高亮仍处于延迟状态,符合规范 Phase 2 的决策——在rangevsnode目标语义明确之前不接入。相关计划(如 2026-04-09-editor-behavior-replan-next-batch.md)仍将其列为"shared navigation feedback for search jumps"待办。
7. 交接与协作指引
契约文档还给出了执行协作的参考:
- 建议推理强度(by lane):core 契约 + 包边界为
high;功能集成、测试与浏览器验证为medium; - 按角色分工:
architect压测 core/插件归属与 API 形态;executor实现 core 插件与首批消费者;test-engineer补充单元/集成/浏览器覆盖;code-reviewer做最终 API 与分层评审;verifier提供完成证据; - 验证路径:包测试先行 → 应用集成测试其次 → 浏览器验证最后,之后才把契约视为"真实"。
8. 小结:把"跳转后高亮"沉淀为共享编辑器定律
回顾2026-04-06-navigation-feedback-spec.md的 Outcome,本规范要达成的三项目标在仓库中均有对应:
EDIT-NAV-FEEDBACK-*成为共享跨界面定律——已写入 markdown-editing-spec.md(locked)与 editor-protocol-matrix.md;- TOC、脚注、搜索跳转显式复用同一个瞬态导航反馈原语——脚注与 TOC 已在源码中调用
editor.tf.navigation.flashTarget/navigate,搜索按计划延迟; - 架构文档将导航反馈视为共享编辑器领域而非每功能各写各的 hack——editor-behavior-architecture.md 承接此定位。
对于后续想要扩展新跳转面(讨论/评论锚点、自定义大纲、未来的 range 高亮)的开发者,正确姿势是:功能包只负责目标解析(resolve target),然后调用 core 的editor.tf.navigation.flashTarget(...)或editor.tf.navigation.navigate(...),让共享契约统一处理焦点、滚动、闪烁与状态替换——这就是本文从规范到源码所呈现的完整闭环。
<输出文章>
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考