Plate 目录(TOC)交互打磨实战:活动标题状态、点击导航反馈与 aria-current 的正确实现
2026/9/16 10:00:22 网站建设 项目流程

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交互打磨计划展开,完整还原了该轮工作如何让实时 TOC(Table 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)为这项工作划定了清晰的范围(Scope):

  • TOC 活动标题(active-heading)状态:实时编辑器 UI 中,当前阅读位置的标题高亮;
  • TOC 点击导航反馈与选区行为:点击目录项后,页面如何滚动、如何给出视觉反馈、是否影响文本选区;
  • TOC 文档 / Demo 同步:如果运行时契约发生变化,确保文档与演示保持一致。

同时明确列出了非目标(Non-Goals),防止范围蔓延:

  • 不引入新的 Markdown 语法;
  • 不做跨文件大纲或应用壳层(app-shell)搜索;
  • 不做超出 TOC 当前需求的宽泛导航契约架构改造。

注意:范围控制是这个计划的关键设计决策。它刻意把"共享导航反馈契约"的架构工作限定在 TOC 需要的边界内,为后续独立文档 docs/plans/2026-04-06-navigation-feedback-contract.md 中的长期设计预留空间。

二、现状调研:问题出在"状态存在但没被用上"

计划执行的第一步是盘点现状。调查结论记录在计划文档的 "Current Findings" 部分,一共四条关键发现:

  1. packages/toc已有活动标题追踪能力,分布在useContentControlleruseTocSideBarState两个 hook 中,且已有对应的单元测试(useContentController.spec.tsx、useTocSideBar.spec.tsx);
  2. 注册表 TOC 节点(registry UI)没有使用该活动状态apps/www/src/registry/ui/toc-node.tsx中的TocElement组件完全忽略了包层 hook 输出的activeContentId
  3. 同一个 TOC 节点把aria-current渲染到了每一行,这是错误的无障碍语义——aria-current只能标注当前项;
  4. TOC 点击已经调用了editor.tf.navigation.flashTarget(...),因此实时 UI 应当复用现有导航反馈,而不是另起炉灶发明新机制。

此外,仓库中已有一份共享导航反馈契约草案 docs/plans/2026-04-06-navigation-feedback-contract.md,本轮工作以它为**指导(guidance)**而非阻塞项(blocker)。

2.1 活动标题追踪到底是怎么实现的?

在深入修复之前,先理解"活动标题状态"的底层机制。核心逻辑在 useContentController.ts 与 useContentObserver.ts 中:

  • useContentController接收containerRefisObserverootMargintopOffset等参数;
  • 它内部通过useContentObserver使用IntersectionObserver观察所有标题元素:当某标题进入视口时,其id被写入activeId
  • useContentController将其提升为activeContentId状态,同时对外暴露onContentScroll作为"点击目录项后的滚动 + 反馈"统一入口;
  • 观察逻辑的关键细节:只有当isScroll(内容区可滚动)为真时,observer 的root才是内容容器,否则以window为根;每次滚动事件会更新status触发重新观察。

对应的滚动控制由 useTocController.ts 完成:当活动标题在 TOC 自身可视区内不可见(visible为假)时,自动把 TOC 列表滚动到对应条目位置,保证活动项始终可见——这就是 Sidebar 式目录"跟随阅读位置自动滚动"的实现。

三、核心修复一:useTocElementState复用统一的内容控制器

计划的第一处代码改动是重构useTocElementState,让它复用useContentController并暴露activeContentId,从而取代原先"第二条只服务点击滚动的独立路径"。

重构后的实现见 useTocElement.ts:

export 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)拿到编辑器实例与插件选项(topOffset);
  • useEditorSelector(getHeadingList, [])订阅标题列表,只有列表变化才触发重渲染;
  • useContentController同时产出滚动观察activeContentId)与点击滚动onContentScroll)两条能力,消除了此前重复的滚动路径;
  • 返回值中的onContentScroll是对useContentController回调的包装,统一了behavior默认值(instant)。

配套的交互 hookuseTocElement负责把点击事件翻译成滚动调用:

export const useTocElement = ({ editor, onContentScroll }: ReturnType<typeof 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暴露activeContentIdheadingList,且点击后onContentScroll收到{ behavior, el, id, path }

3.1 Sidebar 形态:useTocSideBarState如何共享同一套状态

与内嵌 TOC 元素平行的还有 Sidebar 形态,由 useTocSideBar.ts 提供。useTocSideBarState同样调用useContentController,并额外组合了:

  • useTocController:让 TOC 列表自身跟随活动项滚动;
  • mouseInToc/setMouseInToc:鼠标悬停 TOC 时暂停内容观察(setIsObserve(false)),避免用户浏览目录时活动高亮被内容滚动"抢走";
  • tocRef:TOC 导航容器引用。

useTocSideBar则产出navProps(含refonMouseEnteronMouseLeave)与onContentClick,其中onMouseLeavecheckIn(e)判断鼠标是否真的离开了 TOC 区域,避免子元素边界抖动(见 useTocSideBar.spec.tsx)。

四、核心修复二:注册表 TOC 节点只有活动行才标注aria-current

修复的第二个落点是注册表 UI 组件 toc-node.tsx。此前它把aria-current渲染到每一行,这在无障碍语义上是错误的:aria-current="location"只能出现在当前激活的那一项上。

修复后的关键代码:

export function TocElement(props: PlateElementProps) { const state = useTocElementState(); const { props: btnProps } = useTocElement(state); const { activeContentId, headingList } = state; return ( <PlateElement {...props} className="mb-1 p-0"> <div contentEditable={false}> {headingList.length > 0 ? ( headingList.map((item) => ( <Button key={item.id} variant="ghost" 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 className="text-gray-500 text-sm"> Create a heading to display the table of contents. </div> )} </div> {props.children} </PlateElement> ); }

改动要点:

  • 活动判定item.id === activeContentId才设置aria-current="location",其余行传undefined,彻底修正"每一行都是 current"的错误;
  • 活动样式:通过cva(class-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是节点 id,path是 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 点击导航的完整调用链:

  1. 立即更新活动状态setActiveContentId(id)让 TOC 高亮立刻切换到被点击的标题;
  2. 滚动定位:优先滚动内容容器(scrollTo),否则滚动window;滚动位置由heightToTop(el) - topOffset计算,其中topOffset用于抵消吸顶导航栏等固定元素的高度;
  3. 导航反馈:携带标题的 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 减去topOffset5);
  • flashTarget{ 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 = createTSlatePlugin<NavigationFeedbackConfig>({ key: NAVIGATION_FEEDBACK_KEY, options: { activeTarget: null, duration: 1600, }, }) .extendEditorApi<NavigationFeedbackConfig['api']>(({ editor }) => ({ navigation: { activeTarget: getActiveTarget, clear: () => clearNavigationFeedbackTarget(editor), isTarget: (path) => { ... }, }, })) .extendEditorTransforms<NavigationFeedbackConfig['transforms']>( ({ editor }) => ({ navigation: { clear: () => clearNavigationFeedbackTarget(editor), flashTarget: (options) => flashTarget(editor, options), navigate: (options) => navigate(editor, options), }, }) );

类型定义(types.ts)给出了完整的契约:

  • 选项(Options)activeTarget: NavigationFeedbackStoredTarget | nullduration: number(默认 1600ms);
  • APInavigation.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 是这套机制的核心,其实现体现了"确定性替换 + 自动清除 + 路径同步"三个关键设计:

  1. 脉冲计数(pulse):每次调用nextPulse(editor)让脉冲号 +1,cycle = pulse % 2取 0/1 交替,供 CSS 动画区分"首次进入"与"再次进入";

  2. 确定性替换:调用前先clearNavigationTimeout清除旧定时器、clearNavigationElement摘除旧 DOM 属性、clearNavigationPathRef释放旧 PathRef,保证新的 flash 必然覆盖旧的;

  3. PathRef 路径同步:目标以editor.api.pathRef(target.path)保存,后续文档结构变化(插入/删除节点)时 PathRef 自动跟随,活动目标不会指向失效位置;

  4. DOM 属性注入:通过setNavigationElement在目标元素上写入:

    data-nav-cycle → cycle(0 | 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不变的前提下,flashTargetdata-nav-highlight变为'navigated'data-nav-pulse变为'1'clear()后属性被移除。

    6.5 核心插件测试全景

    NavigationFeedbackPlugin.spec.ts 覆盖了以下行为,可作为理解契约的"可执行规格":

    测试场景验证点
    flashTarget 设置并清除目标设置后activeTarget结构完整,超时回调后归null
    新 flash 替换旧 flash旧定时器被clearTimeout,pulse 递增,cycle 交替
    navigate 依次执行 select/focus/scroll/flash选区被设置、tf.focusscrollIntoView被调用、目标状态就位
    目标节点移动后路径同步插入节点后activeTarget.path[0]变为[1]isTarget正确
    目标节点被删除activeTarget()返回null,存储被清空
    顶层选项覆盖 durationnavigationFeedback: { duration: 1200 }生效
    可禁用插件navigationFeedback: false时插件不在pluginListapi.navigation/tf.navigationundefined

    七、与导航反馈契约(Navigation Feedback Contract)的关系

    本轮 TOC 打磨并非孤立工作。计划文档明确引用了 docs/plans/2026-04-06-navigation-feedback-contract.md 作为指导。该契约文档定义了一个长期愿景:TOC、footnote、搜索跳转、未来的锚点面板都应复用同一个编辑器级导航反馈原语,而不是各自实现本地 flash、overlay hack 或选区修复技巧。

    契约的核心决策包括:

    • 永久归属:契约放在@platejs/core,作为共享导航插件;不放在selectionfloating或功能包内;
    • 两层结构:lib 层拥有规范契约与 transforms(flashTarget/navigate),React 层只做渲染消费的薄适配(hook + nodeProps 注入);
    • 两种一等消费者模式selection-driven navigate(footnote 跳转)与flash-only target feedback(TOC 点击);
    • 目标解析留在功能包: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 遵循"先写失败测试、再最小实现、最后验证"的顺序,验证矩阵包括:

    1. 针对性测试toc包 hooks 测试(useContentControlleruseTocElementuseTocSideBar)+ app 层规格;
    2. 包构建与类型检查:package build / typecheck;
    3. 注册表构建www build:registry(确保apps/www/src/registry/ui/toc-node.tsx改动被正确发布进注册表产物,如apps/www/public/r/registry.jsontoc-docs.json);
    4. Lintlint:fix
    5. 浏览器验证: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返回带onClickprops
    • useTocSideBarState返回activeContentIdheadingListmouseInTocopensetIsObservesetMouseInToctocRefonContentScroll
    • useTocSideBar返回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/toc
    import { 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),跳过空标题,按h1h6映射深度,产出Heading[]

    此外,若滚动元素不是编辑容器本身,还需要按 toc.mdx/(elements)/toc.mdx) 的 "Scroll Container Setup" 一节传入滚动容器 ref(useEditorContainerRef()useEditorScrollRef()),这直接影响useContentControllerisScroll的判定与滚动目标的选择。

    十、经验总结:本轮打磨沉淀的工程原则

    从 docs/plans/2026-04-06-toc-interaction-polish.md 及其配套实现中,可以提炼出几条可复用的工程原则:

    1. 状态先查再用activeContentId早已存在于包层 hook,问题是注册表 UI 没有消费它。修 bug 前先盘点"能力是否已经存在",避免重复实现;
    2. 复用统一机制:点击反馈复用editor.tf.navigation.flashTarget,而不是在 TOC 内再造一套 flash 逻辑;共享契约的价值在于"一处时序、处处一致";
    3. 无障碍语义要精确aria-current只能标注当前项;活动样式与aria-current必须由同一个状态源(activeContentId)驱动,防止视觉与语义漂移;
    4. 区分消费者模式:TOC(flash-only、无选区)与 footnote(selection-driven)不是同一个原语,契约应显式暴露两种模式而非强行归一;
    5. 先测试后实现:先加失败测试(如useContentController.spec.tsx断言flashTarget被调用且不进选区模式),再实现最小修复,最后用构建、lint、浏览器验证闭环。

    对于正在集成 Plate TOC 的开发者,最终检查清单是:活动行高亮与aria-current="location"是否由activeContentId单一驱动;点击目录项是否平滑滚动且触发节点 flash;滚动观察在localhost下是否验证通过;topOffset是否与你的固定导航栏高度匹配。

    <输出文章>

    【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询