目录节点

Available in Start plan

一个为 Tiptap 编辑器提供的全面目录(TOC)组件系统。此包提供了可嵌入编辑器内容的内联目录节点以及用于文档导航带进度指示的浮动侧边栏组件。

安装

通过 Tiptap CLI 添加组件:

npx @tiptap/cli@latest add toc-node

使用

基础集成

要为你的 Tiptap 编辑器添加目录功能,请按以下步骤操作:

1. 导入所需的扩展和组件:

import { Heading } from '@tiptap/extension-heading'
import { TableOfContents } from '@tiptap/extension-table-of-contents'
import { TocNode, TocSidebar, TocShowTitleButton } from '@/components/tiptap-node/toc-node'
import { TocProvider, useToc } from '@/components/tiptap-node/toc-node/context/toc-context'

// 导入所需样式
import '@/components/tiptap-node/toc-node/toc-node.scss'
import '@/components/tiptap-node/toc-node/ui/toc-sidebar/toc-sidebar.scss'

2. 在编辑器配置中添加扩展:

function EditorComponent() {
  const { setTocContent } = useToc()

  const editor = useEditor({
    extensions: [
      StarterKit,
      Heading.configure({
        levels: [1, 2, 3, 4, 5, 6],
      }),
      TableOfContents.configure({
        getIndex: (headingNode) => headingNode.attrs.id,
        onUpdate: (content) => {
          setTocContent(content) // 更新目录状态
        },
      }),
      TocNode.configure({
        topOffset: 80,
        maxShowCount: 20,
        showTitle: true,
      }),
    ],
    content: '<p>你的内容这里</p>',
  })
}

3. 将目录组件添加到你的编辑器:

import { TocProvider } from '@/components/tiptap-node/toc-node/context/toc-context'
import { TocSidebar } from '@/components/tiptap-node/toc-node'
;<TocProvider>
  <EditorContext.Provider value={{ editor }}>
    {/* 工具栏,包含目录按钮 */}
    <div className="toolbar">
      <button onClick={() => editor?.commands.insertTocNode()}>插入目录</button>
      <TocShowTitleButton editor={editor} />
    </div>

    <div className="editor-layout">
      <EditorContent editor={editor} />

      {/* 浮动侧边栏 */}
      <TocSidebar maxShowCount={20} topOffset={80} />
    </div>
  </EditorContext.Provider>
</TocProvider>

完成! 你的编辑器现在具有:

  • 可插入文档的内联目录节点
  • 带进度指示的浮动目录侧边栏
  • 自动标题检测和导航
  • 支持 URL 哈希深度链接

完整功能示例请参见下方的完整示例章节。

创建目录

插入目录节点

目录系统提供了两种主要显示目录的方式:

1. 内联目录节点

一个可放置在文档内容任意位置的内联节点:

// 通过代码插入
editor.commands.insertTocNode({
  maxShowCount: 10,
  topOffset: 60,
  showTitle: true,
})

2. 浮动目录侧边栏

一个悬浮在编辑器旁的侧边栏组件:

<TocSidebar maxShowCount={20} topOffset={80} className="my-toc-sidebar" />

组件

<TocNode /> 扩展

一个可直接嵌入文档的内联目录节点。 此扩展创建一个可拖拽、可选中的块,自动跟随标题变化更新。

主要特点

  • 自动更新:自动反映文档中的标题更改
  • 可定制显示:控制最大显示标题数,标题显示与滚动偏移
  • 智能导航:点击可平滑滚动至对应标题
  • 空状态:无标题时显示提示信息
  • 深度归一:正确的层级嵌套显示标题

使用示例

import { TocNode } from '@/components/tiptap-node/toc-node'
import { TableOfContents } from '@tiptap/extension-table-of-contents'

const editor = useEditor({
  extensions: [
    TableOfContents.configure({
      getIndex: (headingNode) => headingNode.attrs.id,
      onUpdate: (content) => {
        // 处理更新
      },
    }),
    TocNode.configure({
      topOffset: 80,
      maxShowCount: 20,
      showTitle: true,
      HTMLAttributes: {
        class: 'my-custom-toc',
      },
    }),
  ],
})

// 插入节点
editor.commands.insertTocNode({
  maxShowCount: 15,
  topOffset: 60,
})

配置选项

选项类型默认值说明
topOffsetnumber0滚动到标题时距离视口顶部的偏移(像素)
maxShowCountnumber20目录中最大显示的标题数量
showTitlebooleantrue是否显示“目录”标题
HTMLAttributesRecord<string, any>{}添加到目录节点元素的 HTML 属性

命令

此扩展提供 insertTocNode 命令:

// 使用默认配置插入
editor.commands.insertTocNode()

// 自定义属性插入
editor.commands.insertTocNode({
  maxShowCount: 10,
  topOffset: 100,
  showTitle: false,
})

<TocSidebar />

一个浮动的侧边栏组件,显示目录并附带视觉进度指示。 该组件提供一个始终可见的导航面板,包含高级状态管理和平滑滚动功能。

主要特点

  • 进度轨迹:显示文档当前位置的视觉进度指示
  • 多级激活状态
    1. 手动点击(最高优先)
    2. 滚动位置检测
    3. 光标/选择位置
    4. 首个标题(备用)
  • 智能滚动:仅在目标标题不可见时滚动
  • URL 哈希支持:页面加载时从 URL 恢复滚动位置
  • 平滑过渡:动画形式切换激活状态

使用示例

import { TocProvider } from '@/components/tiptap-node/toc-node/context/toc-context'
import { TocSidebar } from '@/components/tiptap-node/toc-node'

function MyEditor() {
  return (
    <TocProvider>
      <div className="editor-container">
        <EditorContent editor={editor} />
        <TocSidebar maxShowCount={20} topOffset={80} className="custom-sidebar" />
      </div>
    </TocProvider>
  )
}

属性

属性类型默认值说明
maxShowCountnumber20TOC 中最大显示的标题数量
topOffsetnumber0距离编辑器容器顶部的偏移(像素)
classNamestring-额外的 CSS 类名
所有标准 HTML div 属性--支持所有 HTMLAttributes<HTMLDivElement>

激活状态逻辑

侧边栏智能判定当前激活的标题:

  1. 手动点击:用户点击目录项后,该项保持激活
  2. 滚动检测:完成手动导航后,切换为基于滚动位置检测
  3. 光标位置:高亮当前光标所在的标题
  4. 备用:以上条件不满足时显示第一个标题

<TocShowTitleButton />

一个工具栏按钮,用于切换目录节点标题的显示。 该按钮仅在编辑器中选中目录节点时显示。

使用示例

import { TocShowTitleButton } from '@/components/tiptap-node/toc-node'

function MyToolbar() {
  return (
    <div className="toolbar">
      <TocShowTitleButton
        editor={editor}
        text="显示标题"
        hideWhenUnavailable={true}
        onToggle={(isActive) => {
          console.log('标题显示状态:', isActive)
        }}
      />
    </div>
  )
}

属性

属性类型默认值说明
editorEditor | null-TipTap 编辑器实例
textstring-图标旁边显示的可选文本
hideWhenUnavailablebooleanfalse无目录节点选中时是否隐藏按钮
onToggle(active: boolean) => void-切换状态变化时的回调函数
所有标准按钮属性--支持所有 ButtonProps

使用 Hook 自定义实现

如需实现自定义按钮,可使用 useTocShowTitle Hook:

import { useTocShowTitle } from '@/components/tiptap-node/toc-node'

function CustomShowTitleButton() {
  const { isVisible, isActive, canToggle, handleToggle, label, Icon } = useTocShowTitle({
    editor,
    hideWhenUnavailable: true,
    onToggle: (isActive) => {
      console.log('标题显示状态:', isActive)
    },
  })

  if (!isVisible) return null

  return (
    <button onClick={handleToggle} disabled={!canToggle} aria-label={label} data-active={isActive}>
      <Icon /> {label}
    </button>
  )
}

该 Hook 返回:

  • isVisible: boolean - 按钮是否应显示
  • isActive: boolean - 目录标题当前是否显示
  • canToggle: boolean - 是否可执行切换操作
  • handleToggle: () => boolean - 执行切换操作
  • label: string - 按钮显示的标签
  • Icon: ComponentType - 按钮图标组件

上下文与钩子

<TocProvider />

一个管理目录状态并向所有子组件提供导航方法的上下文提供者。

使用示例

import { TocProvider } from '@/components/tiptap-node/toc-node/context/toc-context'

function App() {
  return (
    <TocProvider>
      <YourEditorComponents />
    </TocProvider>
  )
}

useToc()

在提供者范围内的任意组件中访问目录状态和方法。

API

const {
  tocContent, // TableOfContentData | null
  setTocContent, // (value: TableOfContentData | null) => void
  navigateToHeading, // (item, options?) => void
  normalizeHeadingDepths, // <T>(items: T[]) => number[]
} = useToc()

方法

navigateToHeading(item, options?)

滚动至某个标题,并更新编辑器中的选区。

navigateToHeading(item, {
  topOffset: 80, // 顶部偏移(像素)
  behavior: 'smooth', // 'smooth' | 'auto'
})

参数说明:

  • item: TableOfContentDataItem - 要导航的标题项
  • options.topOffset?: number - 视口顶部偏移,默认 0
  • options.behavior?: ScrollBehavior - 滚动行为,默认 'smooth'

normalizeHeadingDepths(items)

规范化标题深度以实现正确的层级嵌套。确保标题只嵌套于比它层级数更小的前置标题下,避免出现错误结构。

const items = [
  { level: 2, textContent: '章节 1' },
  { level: 4, textContent: '子章节' }, // h4 不能是 h2 的子项
  { level: 3, textContent: '另一个章节' },
]

const depths = normalizeHeadingDepths(items)
// 返回: [1, 2, 2]
// h4 被调整为和正确嵌套的 h3 同层级

算法步骤:

  1. 将所有级别重置,使最小级别变为 1(根层级)
  2. 对每个标题,查找其之前最近的更小级别标题
  3. 找到时,将当前标题当作该父级标题的子级(父深度 + 1)
  4. 找不到时,视为根层级项(深度 = 1)

完整示例

以下是集成所有组件的完整示例:

import { useEditor, EditorContent, EditorContext } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { Heading } from '@tiptap/extension-heading'
import { TableOfContents } from '@tiptap/extension-table-of-contents'
import { TocProvider, useToc } from '@/components/tiptap-node/toc-node/context/toc-context'
import { TocNode, TocSidebar, TocShowTitleButton } from '@/components/tiptap-node/toc-node'

import '@/components/tiptap-node/toc-node/toc-node.scss'
import '@/components/tiptap-node/toc-node/ui/toc-sidebar/toc-sidebar.scss'

function EditorComponent() {
  const { setTocContent } = useToc()

  const editor = useEditor({
    extensions: [
      StarterKit,
      Heading.configure({
        levels: [1, 2, 3, 4, 5, 6],
      }),
      TableOfContents.configure({
        getIndex: (headingNode) => headingNode.attrs.id,
        onUpdate: (content) => {
          setTocContent(content)
        },
      }),
      TocNode.configure({
        topOffset: 80,
        maxShowCount: 20,
        showTitle: true,
      }),
    ],
    content: `
      <h1 id="introduction">介绍</h1>
      <p>欢迎阅读关于目录的综合文档…</p>
      <h2 id="getting-started">快速开始</h2>
      <p>我们先安装所需包…</p>
      <h3 id="installation">安装</h3>
      <p>运行以下命令进行安装…</p>
      <h3 id="configuration">配置</h3>
      <p>使用以下选项配置编辑器…</p>
      <h2 id="usage">使用</h2>
      <p>下面介绍目录组件的用法…</p>
      <h3 id="inline-toc">内联目录节点</h3>
      <p>直接在文档内插入目录节点…</p>
      <h3 id="sidebar-toc">侧边栏目录</h3>
      <p>添加一个悬浮侧栏来导航…</p>
      <h2 id="advanced">高级功能</h2>
      <p>探索更多高级用法…</p>
    `,
  })

  return (
    <EditorContext.Provider value={{ editor }}>
      <div className="editor-wrapper">
        {/* 工具栏 */}
        <div className="toolbar">
          <button
            onClick={() => editor?.commands.insertTocNode()}
            disabled={!editor?.can().insertTocNode()}
          >
            插入目录
          </button>
          <TocShowTitleButton editor={editor} text="切换标题" hideWhenUnavailable={true} />
        </div>

        {/* 编辑器与侧边栏 */}
        <div className="editor-content-wrapper">
          <EditorContent editor={editor} className="editor-content" />
          <TocSidebar maxShowCount={20} topOffset={80} className="editor-toc-sidebar" />
        </div>
      </div>
    </EditorContext.Provider>
  )
}

export default function App() {
  return (
    <TocProvider>
      <EditorComponent />
    </TocProvider>
  )
}

样式

目录组件包含默认样式,可通过 CSS 或 SCSS 自定义。

必需样式表

import '@/components/tiptap-node/toc-node/toc-node.scss'
import '@/components/tiptap-node/toc-node/ui/toc-sidebar/toc-sidebar.scss'

TocNode 的 CSS 类

.tiptap-table-of-contents-node {
  // 目录节点主容器
  padding: 1rem;
  border: 1px solid var(--border-color);
  border-radius: 0.5rem;
}

.tiptap-table-of-contents-title {
  // 标题 “目录”
  font-size: 1.25rem;
  font-weight: 600;
  margin-bottom: 0.5rem;
}

.tiptap-table-of-contents-list {
  // 导航列表容器
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.tiptap-table-of-contents-item {
  // 单个目录项
  padding: 0.5rem;
  border-radius: 0.25rem;
  text-decoration: none;
  color: var(--text-color);
  transition: background-color 0.2s;

  &:hover {
    background-color: var(--hover-bg);
  }

  // 基于深度的缩进
  &[data-depth='1'] {
    padding-left: 0.5rem;
  }
  &[data-depth='2'] {
    padding-left: 1.5rem;
  }
  &[data-depth='3'] {
    padding-left: 2.5rem;
  }
}

.tiptap-table-of-contents-empty {
  // 空状态提示
  color: var(--text-muted);
  font-style: italic;
}

TocSidebar 的 CSS 类

.toc-sidebar {
  // 侧边栏主容器
  position: sticky;
  top: 80px;
  max-height: calc(100vh - 100px);
  overflow-y: auto;
}

.toc-sidebar-wrapper {
  // 内层包装容器
  display: flex;
  gap: 0.75rem;
}

.toc-sidebar-progress {
  // 进度轨迹容器
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  width: 3px;
}

.toc-sidebar-progress-line {
  // 单条进度线
  height: 1.5rem;
  background-color: var(--progress-inactive);
  transition: background-color 0.2s;

  &--active {
    background-color: var(--progress-active);
  }
}

.toc-sidebar-nav {
  // 导航容器
  flex: 1;
}

.toc-sidebar-item {
  // 侧边栏导航项
  display: block;
  padding: 0.375rem 0.5rem;
  border-radius: 0.25rem;
  text-decoration: none;
  color: var(--text-color);
  font-size: 0.875rem;
  line-height: 1.5;
  transition: all 0.2s;

  &:hover {
    background-color: var(--hover-bg);
  }

  &--active {
    font-weight: 600;
    color: var(--active-color);
  }
}

自定义深度样式

两个组件均暴露了 --toc-depth CSS 变量,用于自定义缩进:

.tiptap-table-of-contents-item {
  // 使用 CSS 变量实现动态缩进
  padding-left: calc(var(--toc-depth, 1) * 1rem);
}

.toc-sidebar-item {
  // 侧边栏调整(深度 1 = 无缩进)
  padding-left: calc((var(--toc-depth, 1) - 1) * 0.75rem);

  // 可选:深度的视觉指示器
  &::before {
    content: '';
    display: inline-block;
    width: calc(var(--toc-depth, 1) * 4px);
    height: 2px;
    background: currentColor;
    margin-right: 0.5rem;
    opacity: 0.3;
  }
}

暗黑模式支持

使用 CSS 变量或基于类的主题为暗黑模式添加样式:

:root {
  --toc-bg: #ffffff;
  --toc-text: #1a1a1a;
  --toc-border: #e5e5e5;
  --toc-hover: #f5f5f5;
  --toc-active: #0066ff;
  --toc-progress-inactive: #e5e5e5;
  --toc-progress-active: #0066ff;
}

.dark {
  --toc-bg: #1a1a1a;
  --toc-text: #ffffff;
  --toc-border: #333333;
  --toc-hover: #2a2a2a;
  --toc-active: #4d9aff;
  --toc-progress-inactive: #333333;
  --toc-progress-active: #4d9aff;
}

依赖

必需包

  • @tiptap/core - TipTap 核心功能
  • @tiptap/react - TipTap React 集成
  • @tiptap/extension-table-of-contents - 目录内容提取
  • @tiptap/extension-heading - 标题节点(作为目录源)
  • reactreact-dom - React 框架

可选包

  • @tiptap/extension-unique-id - 自动生成标题 ID
  • sass / sass-embedded - 用于 SCSS 编译(若使用 SCSS)

功能特性

  • 内联目录节点:直接在文档中嵌入目录
  • 浮动侧边栏:始终可见的导航面板
  • 自动更新:标题发生变动时实时更新
  • 智能导航:智能滚动和激活状态管理
  • 进度指示器:显示文档阅读进度的轨迹
  • 多级检测:支持点击、滚动以及光标定位高亮
  • 深度规范化:正确的层级标题结构
  • URL 哈希支持:从 URL 恢复滚动位置
  • 空状态显示:无标题时提供提示信息
  • 平滑滚动:动画滚动到指定标题
  • 可配置显示:控制最大项目数、标题显示、滚动偏移
  • 键盘可访问:支持完整的键盘导航
  • 暗黑模式:内置暗黑主题支持
  • TypeScript:完善的类型定义
  • 样式可定制:支持 CSS 变量与类主题

相关扩展

  • Heading 扩展 - 用于创建标题节点
  • UniqueID 扩展 - 建议用于稳定的标题 ID
  • 目录扩展 - 负责内容提取