交互打磨实战:活动标题状态、点击导航反馈与 aria-current 的正确实现)
Plate 目录TOC交互打磨实战活动标题状态、点击导航反馈与 aria-current 的正确实现【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate导读本文基于 Plate 开源仓库中的docs/plans/2026-04-06-toc-interaction-polish.md交互打磨计划展开完整还原了该轮工作如何让实时 TOCTable of Contents从渲染出来的标题列表进化为真正可用的导航工具复用包内已有的活动标题追踪能力、修复注册表 TOC 节点错误的aria-current渲染、并通过editor.tf.navigation.flashTarget(...)复用统一导航反馈机制。读完本文你将掌握 Plate 中 TOC 活动标题状态的数据流、点击导航的完整调用链、platejs/core导航反馈插件的底层实现以及如何在自有编辑器中正确接入并验证这套交互。一、任务背景TOC 需要更像一个导航工具在 Plate 的实时编辑器中TOC 通常以 Sidebar 或内嵌元素的形式出现。此前的实现虽然在功能上可用但交互层面存在明显短板——它更像是一份渲染出来的标题列表而不是一个导航辅助工具。本轮打磨计划见 docs/plans/2026-04-06-toc-interaction-polish.md为这项工作划定了清晰的范围ScopeTOC 活动标题active-heading状态实时编辑器 UI 中当前阅读位置的标题高亮TOC 点击导航反馈与选区行为点击目录项后页面如何滚动、如何给出视觉反馈、是否影响文本选区TOC 文档 / Demo 同步如果运行时契约发生变化确保文档与演示保持一致。同时明确列出了非目标Non-Goals防止范围蔓延不引入新的 Markdown 语法不做跨文件大纲或应用壳层app-shell搜索不做超出 TOC 当前需求的宽泛导航契约架构改造。注意范围控制是这个计划的关键设计决策。它刻意把共享导航反馈契约的架构工作限定在 TOC 需要的边界内为后续独立文档 docs/plans/2026-04-06-navigation-feedback-contract.md 中的长期设计预留空间。二、现状调研问题出在状态存在但没被用上计划执行的第一步是盘点现状。调查结论记录在计划文档的 Current Findings 部分一共四条关键发现packages/toc已有活动标题追踪能力分布在useContentController与useTocSideBarState两个 hook 中且已有对应的单元测试useContentController.spec.tsx、useTocSideBar.spec.tsx注册表 TOC 节点registry UI没有使用该活动状态。apps/www/src/registry/ui/toc-node.tsx中的TocElement组件完全忽略了包层 hook 输出的activeContentId同一个 TOC 节点把aria-current渲染到了每一行这是错误的无障碍语义——aria-current只能标注当前项TOC 点击已经调用了editor.tf.navigation.flashTarget(...)因此实时 UI 应当复用现有导航反馈而不是另起炉灶发明新机制。此外仓库中已有一份共享导航反馈契约草案 docs/plans/2026-04-06-navigation-feedback-contract.md本轮工作以它为**指导guidance**而非阻塞项blocker。2.1 活动标题追踪到底是怎么实现的在深入修复之前先理解活动标题状态的底层机制。核心逻辑在 useContentController.ts 与 useContentObserver.ts 中useContentController接收containerRef、isObserve、rootMargin、topOffset等参数它内部通过useContentObserver使用IntersectionObserver观察所有标题元素当某标题进入视口时其id被写入activeIduseContentController将其提升为activeContentId状态同时对外暴露onContentScroll作为点击目录项后的滚动 反馈统一入口观察逻辑的关键细节只有当isScroll内容区可滚动为真时observer 的root才是内容容器否则以window为根每次滚动事件会更新status触发重新观察。对应的滚动控制由 useTocController.ts 完成当活动标题在 TOC 自身可视区内不可见visible为假时自动把 TOC 列表滚动到对应条目位置保证活动项始终可见——这就是 Sidebar 式目录跟随阅读位置自动滚动的实现。三、核心修复一useTocElementState复用统一的内容控制器计划的第一处代码改动是重构useTocElementState让它复用useContentController并暴露activeContentId从而取代原先第二条只服务点击滚动的独立路径。重构后的实现见 useTocElement.tsexport const useTocElementState () { const { editor, getOptions } useEditorPlugin(TocPlugin); const { topOffset } getOptions(); const headingList useEditorSelector(getHeadingList, []); const containerRef useScrollRef(); const { activeContentId, onContentScroll } useContentController({ containerRef, isObserve: true, rootMargin: 0px 0px 0px 0px, topOffset, }); const onHeadingScroll React.useCallback( (el: HTMLElement, id: string, behavior: ScrollBehavior instant, path?: Path) { onContentScroll({ behavior, el, id, path }); }, [onContentScroll] ); return { activeContentId, editor, headingList, onContentScroll: onHeadingScroll, }; };要点拆解useEditorPlugin(TocPlugin)拿到编辑器实例与插件选项topOffsetuseEditorSelector(getHeadingList, [])订阅标题列表只有列表变化才触发重渲染useContentController同时产出滚动观察activeContentId与点击滚动onContentScroll两条能力消除了此前重复的滚动路径返回值中的onContentScroll是对useContentController回调的包装统一了behavior默认值instant。配套的交互 hookuseTocElement负责把点击事件翻译成滚动调用export const useTocElement ({ editor, onContentScroll }: ReturnTypetypeof useTocElementState) ({ props: { onClick: (e, item: Heading, behavior: ScrollBehavior) { e.preventDefault(); const { id, path } item; const node NodeApi.get(editor, path); if (!node) return; const el editor.api.toDOMNode(node); if (!el) return; onContentScroll(el, id, behavior, path); }, }, });其测试见 useTocElement.spec.tsx验证useTocElementState暴露activeContentId与headingList且点击后onContentScroll收到{ behavior, el, id, path }。3.1 Sidebar 形态useTocSideBarState如何共享同一套状态与内嵌 TOC 元素平行的还有 Sidebar 形态由 useTocSideBar.ts 提供。useTocSideBarState同样调用useContentController并额外组合了useTocController让 TOC 列表自身跟随活动项滚动mouseInToc/setMouseInToc鼠标悬停 TOC 时暂停内容观察setIsObserve(false)避免用户浏览目录时活动高亮被内容滚动抢走tocRefTOC 导航容器引用。useTocSideBar则产出navProps含ref、onMouseEnter、onMouseLeave与onContentClick其中onMouseLeave用checkIn(e)判断鼠标是否真的离开了 TOC 区域避免子元素边界抖动见 useTocSideBar.spec.tsx。四、核心修复二注册表 TOC 节点只有活动行才标注aria-current修复的第二个落点是注册表 UI 组件 toc-node.tsx。此前它把aria-current渲染到每一行这在无障碍语义上是错误的aria-currentlocation只能出现在当前激活的那一项上。修复后的关键代码export function TocElement(props: PlateElementProps) { const state useTocElementState(); const { props: btnProps } useTocElement(state); const { activeContentId, headingList } state; return ( PlateElement {...props} classNamemb-1 p-0 div contentEditable{false} {headingList.length 0 ? ( headingList.map((item) ( Button key{item.id} variantghost className{headingItemVariants({ active: item.id activeContentId, depth: item.depth as 1 | 2 | 3, })} onClick{(e) btnProps.onClick(e, item, smooth)} aria-current{item.id activeContentId ? location : undefined} {item.title} /Button )) ) : ( div classNametext-gray-500 text-sm Create a heading to display the table of contents. /div )} /div {props.children} /PlateElement ); }改动要点活动判定item.id activeContentId才设置aria-currentlocation其余行传undefined彻底修正每一行都是 current的错误活动样式通过cvaclass-variance-authority的active变体区分活动/非活动行——活动行使用bg-accent text-foreground decoration-foreground非活动行使用text-muted-foreground hover:bg-accent hover:text-foreground缩进层级depth变体按1 / 2 / 3分别提供pl-0.5 / pl-[26px] / pl-[50px]的缩进点击行为onClick使用smooth平滑滚动到对应标题点击走的是平滑滚动而键盘/程序触发的默认行为是instant。注意Heading类型的结构types.ts{ id, depth, path, title, type }其中id是节点 idpath是 Slate 路径depth由标题类型映射h1→1 …h6→6见 getHeadingList.ts。五、核心修复三点击导航复用flashTarget而不是新造轮子计划明确指出TOC 点击已经调用editor.tf.navigation.flashTarget(...)所以实时 UI 应该复用现有导航反馈而不是发明新机制。这条原则在 useContentController.ts 的onContentScroll中得到落实const onContentScroll ({ behavior instant, el, id, path }) { setActiveContentId(id); if (isScrollRef.current) { editorContentRef.current?.scrollTo({ behavior, top: heightToTop(el, editorContentRef) - topOffset, }); } else { const top heightToTop(el) - topOffset; // Note: if behavior smooth, scrolling the toc then clicking the title // immediately will scroll to the wrong position. It should be a chrome bug. window.scrollTo({ behavior, top }); } if (path) { editor.tf.navigation.flashTarget({ target: { path, type: node }, }); } };这段代码揭示了 TOC 点击导航的完整调用链立即更新活动状态setActiveContentId(id)让 TOC 高亮立刻切换到被点击的标题滚动定位优先滚动内容容器scrollTo否则滚动window滚动位置由heightToTop(el) - topOffset计算其中topOffset用于抵消吸顶导航栏等固定元素的高度导航反馈携带标题的 Slatepath调用flashTarget触发统一的瞬态高亮不修改文本选区。源码中的注释还记录了一个浏览器兼容性细节当behavior smooth且 TOC 自身也在滚动时立即点击标题可能滚动到错误位置疑为 Chrome 的 bug。这解释了为什么点击路径默认用smooth、而其他路径默认instant的分工设计。对应的测试 useContentController.spec.tsx 精确验证了这一点——测试名称即为 scrolls the active content target without entering block-selection mode滚动活动内容目标但不进入块选区模式断言activeContentId更新为h1容器以{ behavior: instant, top: 35 }滚动heightToTop返回 50 减去topOffset5flashTarget以{ target: { path: [0], type: node } }被调用。六、底层原理platejs/core的导航反馈插件editor.tf.navigation.flashTarget并非 TOC 包私有实现而是platejs/core中共享导航反馈插件的公开 transform。理解它的内部机制才能真正明白复用的价值。6.1 插件注册与 API 面核心插件在 packages/core/src/lib/plugins/navigation-feedback/NavigationFeedbackPlugin.ts 中通过createTSlatePlugin定义export const NavigationFeedbackPlugin createTSlatePluginNavigationFeedbackConfig({ key: NAVIGATION_FEEDBACK_KEY, options: { activeTarget: null, duration: 1600, }, }) .extendEditorApiNavigationFeedbackConfig[api](({ editor }) ({ navigation: { activeTarget: getActiveTarget, clear: () clearNavigationFeedbackTarget(editor), isTarget: (path) { ... }, }, })) .extendEditorTransformsNavigationFeedbackConfig[transforms]( ({ editor }) ({ navigation: { clear: () clearNavigationFeedbackTarget(editor), flashTarget: (options) flashTarget(editor, options), navigate: (options) navigate(editor, options), }, }) );类型定义types.ts给出了完整的契约选项OptionsactiveTarget: NavigationFeedbackStoredTarget | null、duration: number默认 1600msAPInavigation.activeTarget()读取当前活动目标、navigation.clear()清除、navigation.isTarget(path)判断某路径是否为目标Transformsnavigation.clear()、navigation.flashTarget(options)、navigation.navigate(options)。flashTarget的入参结构type NavigationFlashTargetOptions { duration?: number; target: { path: Path; type: node }; variant?: string; // 默认 navigated };6.2flashTarget的完整生命周期flashTarget.ts 是这套机制的核心其实现体现了确定性替换 自动清除 路径同步三个关键设计脉冲计数pulse每次调用nextPulse(editor)让脉冲号 1cycle pulse % 2取 0/1 交替供 CSS 动画区分首次进入与再次进入确定性替换调用前先clearNavigationTimeout清除旧定时器、clearNavigationElement摘除旧 DOM 属性、clearNavigationPathRef释放旧 PathRef保证新的 flash 必然覆盖旧的PathRef 路径同步目标以editor.api.pathRef(target.path)保存后续文档结构变化插入/删除节点时 PathRef 自动跟随活动目标不会指向失效位置DOM 属性注入通过setNavigationElement在目标元素上写入data-nav-cycle → cycle0 | 1>transformProps: ({ element, props, text }) { const activeTarget useNavigationHighlight(element ?? text); if (!activeTarget) return props; return { ...props, data-nav-cycle: String(activeTarget.cycle), data-nav-highlight: activeTarget.variant, data-nav-pulse: String(activeTarget.pulse), data-nav-target: true, style: { ...(props.style ?? {}), --plate-nav-feedback-duration: ${activeTarget.duration}ms, }, }; },配套 hook useNavigationHighlight.ts 使用useEditorSelector订阅editor.api.navigation.activeTarget()并把当前渲染节点的路径Array.isArray(currentTarget)时直接用否则editor.api.findPath与活动目标路径做PathApi.equals比较相等才返回活动目标。这里有一个硬性要求被专门验证过导航目标变化必须触发渲染更新从而让inject.nodeProps能增删高亮属性且不依赖选区变动。React 层测试 NavigationFeedbackPlugin.spec.tsx 为此提供了证据在保持editor.selection不变的前提下flashTarget后data-nav-highlight变为navigated、data-nav-pulse变为1clear()后属性被移除。6.5 核心插件测试全景NavigationFeedbackPlugin.spec.ts 覆盖了以下行为可作为理解契约的可执行规格测试场景验证点flashTarget 设置并清除目标设置后activeTarget结构完整超时回调后归null新 flash 替换旧 flash旧定时器被clearTimeoutpulse 递增cycle 交替navigate 依次执行 select/focus/scroll/flash选区被设置、tf.focus与scrollIntoView被调用、目标状态就位目标节点移动后路径同步插入节点后activeTarget.path从[0]变为[1]isTarget正确目标节点被删除activeTarget()返回null存储被清空顶层选项覆盖 durationnavigationFeedback: { duration: 1200 }生效可禁用插件navigationFeedback: false时插件不在pluginListapi.navigation/tf.navigation为undefined七、与导航反馈契约Navigation Feedback Contract的关系本轮 TOC 打磨并非孤立工作。计划文档明确引用了 docs/plans/2026-04-06-navigation-feedback-contract.md 作为指导。该契约文档定义了一个长期愿景TOC、footnote、搜索跳转、未来的锚点面板都应复用同一个编辑器级导航反馈原语而不是各自实现本地 flash、overlay hack 或选区修复技巧。契约的核心决策包括永久归属契约放在platejs/core作为共享导航插件不放在selection、floating或功能包内两层结构lib 层拥有规范契约与 transformsflashTarget/navigateReact 层只做渲染消费的薄适配hook nodeProps 注入两种一等消费者模式selection-driven navigatefootnote 跳转与flash-only target feedbackTOC 点击目标解析留在功能包footnote 解析定义/引用路径TOC 解析标题路径/id解析完成后调用共享 API渲染以节点 class / data 属性优先data-nav-target/data-nav-highlight CSS 动画无 rect 测量、无滚动监听、无 overlay 布局overlay 是后期回退而非默认只有真正需要任意文本区间高亮、无稳定渲染节点、复杂多矩形绘制时才考虑且可放在selection包。计划中的 TOC 部分属于Phase 1B非选区消费者保留 TOC 当前的滚动行为复用共享的 flash 时序与替换语义不强制文本选区。而未来 TOC 是否也要落光标被明确视为独立的 UX 决策不允许被偷偷塞进基础契约。八、验证链路测试、构建、lint 与浏览器验证计划的 Working Plan 遵循先写失败测试、再最小实现、最后验证的顺序验证矩阵包括针对性测试toc包 hooks 测试useContentController、useTocElement、useTocSideBar app 层规格包构建与类型检查package build / typecheck注册表构建www build:registry确保apps/www/src/registry/ui/toc-node.tsx改动被正确发布进注册表产物如apps/www/public/r/registry.json、toc-docs.jsonLintlint:fix浏览器验证browser-use 手工验证实时 TOC 的活动高亮与点击反馈。验证过程记录了一个值得注意的环境细节浏览器验证只在localhost:3001上正常工作127.0.0.1:3001会导致 docs 预览卡在Loading...原因是 Next.js dev server 默认阻止了跨源cross-origin的 HMR 资源。这意味着在本地复现验证时应使用localhost而不是 IP 形式访问。九、文档与 Demo 同步计划 Scope 的第三项是TOC 文档 / Demo 同步。运行时契约变更后官方文档必须同步。TOC 的官方文档位于 content/docs/(plugins)/(elements)/toc.mdx/(elements)/toc.mdx)其中对修复后 hooks 的描述与实现完全对齐useTocElementState返回activeContentId当前文档位置的活动标题 ID、headingList标题数组、onContentScroll滚动处理器useTocElement返回带onClick的propsuseTocSideBarState返回activeContentId、headingList、mouseInToc、open、setIsObserve、setMouseInToc、tocRef、onContentScrolluseTocSideBar返回navPropsref/onMouseEnter/onMouseLeave与onContentClick。如果你在自有项目中复现这套交互官方文档给出了两条接入路径toc.mdx/(elements)/toc.mdx)路径一使用TocKit推荐含预配置 UI 组件import { createPlateEditor } from platejs/react; import { TocKit } from /components/editor/plugins/toc-kit; const editor createPlateEditor({ plugins: [ // ...otherPlugins, ...TocKit, ], });路径二手动安装并配置npm install platejs/basic-nodes platejs/tocimport { TocPlugin } from platejs/toc/react; import { H1Plugin, H2Plugin, H3Plugin } from platejs/basic-nodes/react; import { createPlateEditor } from platejs/react; const editor createPlateEditor({ plugins: [ H1Plugin, H2Plugin, H3Plugin, TocPlugin, ], });TocPlugin支持三个选项见 BaseTocPlugin.ts 源码选项类型默认值说明isScrollbooleantrue是否启用滚动行为topOffsetnumber80滚动到标题时的顶部偏移抵消固定导航栏queryHeading(editor) Heading[]内置查询自定义标题查询函数覆盖默认的getHeadingList内置查询 getHeadingList.ts 的逻辑是若配置了queryHeading则直接使用否则遍历editor.api.nodes用isHeading匹配标题节点isHeading.ts跳过空标题按h1–h6映射深度产出Heading[]。此外若滚动元素不是编辑容器本身还需要按 toc.mdx/(elements)/toc.mdx) 的 Scroll Container Setup 一节传入滚动容器 refuseEditorContainerRef()或useEditorScrollRef()这直接影响useContentController中isScroll的判定与滚动目标的选择。十、经验总结本轮打磨沉淀的工程原则从 docs/plans/2026-04-06-toc-interaction-polish.md 及其配套实现中可以提炼出几条可复用的工程原则状态先查再用activeContentId早已存在于包层 hook问题是注册表 UI 没有消费它。修 bug 前先盘点能力是否已经存在避免重复实现复用统一机制点击反馈复用editor.tf.navigation.flashTarget而不是在 TOC 内再造一套 flash 逻辑共享契约的价值在于一处时序、处处一致无障碍语义要精确aria-current只能标注当前项活动样式与aria-current必须由同一个状态源activeContentId驱动防止视觉与语义漂移区分消费者模式TOCflash-only、无选区与 footnoteselection-driven不是同一个原语契约应显式暴露两种模式而非强行归一先测试后实现先加失败测试如useContentController.spec.tsx断言flashTarget被调用且不进选区模式再实现最小修复最后用构建、lint、浏览器验证闭环。对于正在集成 Plate TOC 的开发者最终检查清单是活动行高亮与aria-currentlocation是否由activeContentId单一驱动点击目录项是否平滑滚动且触发节点 flash滚动观察在localhost下是否验证通过topOffset是否与你的固定导航栏高度匹配。输出文章【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考