代码语言

知识点思维导图

8 个知识节点

富文本编辑器(03) - 菜单栏和 slash command

读完后,你应能完成以下任务:

  • 绘制“富文本编辑器(03) - 菜单栏和 slash command / 前言”的关键对象与数据流,解释“TipTap 编辑器 Notion 风格的时候,需要选中文本提供菜单栏,而不是固定在页面,是动态的,然后支持输入 / 唤醒垂直菜单栏,这些都是 simple tiptap 没有的,需要自己实现。”,并用源码位置、日志或 Trace 标注证据。
  • 为“富文本编辑器(03) - 菜单栏和 slash command / 基础 Demo”设计正常与异常输入,验证“编辑器组件只负责创建 Editor 实例、注册扩展和承载正文,菜单状态交给 MenuBar。”,输出首个偏差位置与回归测试结果。
  • 实现“富文本编辑器(03) - 菜单栏和 slash command / 基础按钮组件”的最小代码或配置,检验“按钮是否高亮来自 editor.isActive(),能否执行应由命令的 can() 结果决定;”,输出命令、结果与 Diff,并说明不适用边界。

一、前言

TipTap 编辑器 Notion 风格的时候,需要选中文本提供菜单栏,而不是固定在页面,是动态的,然后支持输入 / 唤醒垂直菜单栏,这些都是 simple tiptap 没有的,需要自己实现。

基于 simple tiptap 的时候,可以把固定的菜单栏引入 @tiptap/extension-bubble-menu 扩展里。

二、基础 Demo

2.1 抽离组件

编辑器组件只负责创建 Editor 实例、注册扩展和承载正文,菜单状态交给 MenuBar。这种边界能让固定工具栏、气泡菜单和移动端菜单复用同一个编辑器命令链,避免每种界面重复初始化编辑器。

'use client'

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import { MenuBar } from './MenuBar'

const TiptapEditor = () => {
  const editor = useEditor({
    extensions: [StarterKit, Underline],
    content: '<p>Hello World! 🌎️</p>',
    immediatelyRender: false,
    editorProps: {
      attributes: {
        class: 'focus:outline-none min-h-80 p-4'
      }
    }
  })

  return (
    <div className="overflow-hidden rounded-lg border border-gray-200">
      <MenuBar editor={editor} />
      <EditorContent editor={editor} />
    </div>
  )
}

export default TiptapEditor

2.2 基础按钮组件

MenuButton 把命令触发、激活态和禁用态收敛成统一接口。按钮是否高亮来自 editor.isActive(),能否执行应由命令的 can() 结果决定;不能只根据图标外观推断当前文档状态。

interface MenuButtonProps {
  onClick: () => void
  isActive?: boolean
  disabled?: boolean
  children: React.ReactNode
  title?: string
}

const MenuButton = ({ onClick, isActive = false, disabled = false, children, title }: MenuButtonProps) => {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={`rounded p-2 transition-colors hover:bg-gray-100 ${isActive ? 'bg-gray-200 text-gray-900' : 'text-gray-600'} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} `}
    >
      {children}
    </button>
  )
}

2.3 菜单栏组件

菜单栏通过 editor.chain().focus().toggleXxx().run() 串起“恢复选区焦点、切换 Mark、提交事务”。漏掉 focus() 时,用户点击工具栏会先丢失正文选区,命令可能作用到错误位置或没有效果。

const MenuBar = ({ editor }: { editor: Editor }) => {
  return (
    <div className="flex gap-1 border-r border-gray-200 pr-2">
      <MenuButton
        onClick={() => editor.chain().focus().toggleBold().run()}
        isActive={editor.isActive('bold')}
        title="粗体 (Ctrl+B)"
      >
        <Bold size={16} />
      </MenuButton>

      <MenuButton
        onClick={() => editor.chain().focus().toggleItalic().run()}
        isActive={editor.isActive('italic')}
        title="斜体 (Ctrl+I)"
      >
        <Italic size={16} />
      </MenuButton>

      <MenuButton
        onClick={() => editor.chain().focus().toggleUnderline().run()}
        isActive={editor.isActive('underline')}
        title="下划线 (Ctrl+U)"
      >
        <Underline size={16} />
      </MenuButton>

      <MenuButton
        onClick={() => editor.chain().focus().toggleStrike().run()}
        isActive={editor.isActive('strike')}
        title="删除线"
      >
        <Strikethrough size={16} />
      </MenuButton>

      <MenuButton
        onClick={() => editor.chain().focus().toggleCode().run()}
        isActive={editor.isActive('code')}
        title="行内代码"
      >
        <Code size={16} />
      </MenuButton>
    </div>
  )
}

三、基于 simple tiptap

import React from 'react'
import { Editor } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'

// --- UI 基础组件 ---
import { ToolbarGroup, ToolbarSeparator } from '@/components/tiptap-ui-primitive/toolbar' // 工具栏相关组件

// --- Tiptap UI 组件 ---
import { HeadingDropdownMenu } from '@/components/tiptap-ui/heading-dropdown-menu' // 标题下拉菜单
import { ImageUploadButton } from '@/components/tiptap-ui/image-upload-button' // 图片上传按钮
import { ListDropdownMenu } from '@/components/tiptap-ui/list-dropdown-menu' // 列表下拉菜单
import { BlockquoteButton } from '@/components/tiptap-ui/blockquote-button' // 引用按钮
import { CodeBlockButton } from '@/components/tiptap-ui/code-block-button' // 代码块按钮
import { ColorHighlightPopover, ColorHighlightPopoverButton } from '@/components/tiptap-ui/color-highlight-popover' // 颜色高亮弹窗组件
import { LinkPopover, LinkButton } from '@/components/tiptap-ui/link-popover' // 链接弹窗组件
import { MarkButton } from '@/components/tiptap-ui/mark-button' // 标记按钮(粗体、斜体等)
import { TextAlignButton } from '@/components/tiptap-ui/text-align-button' // 文本对齐按钮
import { UndoRedoButton } from '@/components/tiptap-ui/undo-redo-button' // 撤销重做按钮

interface TextMenuProps {
  editor: Editor
  onHighlighterClick?: () => void
  onLinkClick?: () => void
  isMobile?: boolean
}

/**
 * 主工具栏内容组件
 * 这是编辑器的核心工具栏,包含了所有主要的编辑功能按钮
 */
const MainToolbarContent = ({
  onHighlighterClick = () => {},
  onLinkClick = () => {},
  isMobile = false
}: {
  onHighlighterClick: () => void
  onLinkClick: () => void
  isMobile: boolean
}) => {
  return (
    <>
      {/* 第一组:撤销/重做操作 */}
      <ToolbarGroup>
        <UndoRedoButton action="undo" /> {/* 撤销操作 */}
        <UndoRedoButton action="redo" /> {/* 重做操作 */}
      </ToolbarGroup>

      <ToolbarSeparator />

      {/* 第二组:文档结构工具 */}
      <ToolbarGroup>
        {/* 标题下拉菜单:支持 H1-H4 标题 */}
        <HeadingDropdownMenu levels={[1, 2, 3, 4]} portal={isMobile} />
        {/* 列表下拉菜单:支持无序列表、有序列表、任务列表 */}
        <ListDropdownMenu types={['bulletList', 'orderedList', 'taskList']} portal={isMobile} />
        <BlockquoteButton /> {/* 引用块按钮 */}
        <CodeBlockButton /> {/* 代码块按钮 */}
      </ToolbarGroup>

      <ToolbarSeparator />

      {/* 第三组:文本格式工具 */}
      <ToolbarGroup>
        <MarkButton type="bold" /> {/* 粗体 */}
        <MarkButton type="italic" /> {/* 斜体 */}
        <MarkButton type="strike" /> {/* 删除线 */}
        <MarkButton type="code" /> {/* 行内代码 */}
        <MarkButton type="underline" /> {/* 下划线 */}
        {/* 高亮工具:桌面端显示弹窗,移动端显示按钮 */}
        {!isMobile ? <ColorHighlightPopover /> : <ColorHighlightPopoverButton onClick={onHighlighterClick} />}
        {/* 链接工具:桌面端显示弹窗,移动端显示按钮 */}
        {!isMobile ? <LinkPopover /> : <LinkButton onClick={onLinkClick} />}
      </ToolbarGroup>

      <ToolbarSeparator />

      {/* 第四组:上标下标工具 */}
      <ToolbarGroup>
        <MarkButton type="superscript" /> {/* 上标 */}
        <MarkButton type="subscript" /> {/* 下标 */}
      </ToolbarGroup>

      <ToolbarSeparator />

      {/* 第五组:文本对齐工具 */}
      <ToolbarGroup>
        <TextAlignButton align="left" /> {/* 左对齐 */}
        <TextAlignButton align="center" /> {/* 居中对齐 */}
        <TextAlignButton align="right" /> {/* 右对齐 */}
        <TextAlignButton align="justify" /> {/* 两端对齐 */}
      </ToolbarGroup>

      <ToolbarSeparator />

      {/* 第六组:媒体插入工具 */}
      <ToolbarGroup>
        <ImageUploadButton text="Add" /> {/* 图片上传按钮 */}
      </ToolbarGroup>

      {/* 移动端额外分隔线 */}
      {isMobile && <ToolbarSeparator />}
    </>
  )
}

const TextMenu: React.FC<TextMenuProps> = ({
  editor,
  onHighlighterClick = () => {},
  onLinkClick = () => {},
  isMobile = false
}) => {
  if (!editor) return null

  return (
    <>
      {/* 文本选择时的气泡菜单 - 使用完整的 MainToolbarContent */}
      <BubbleMenu editor={editor} options={{ placement: 'bottom', offset: 8, flip: true }}>
        <div className="bubble-menu flex items-center rounded-md bg-white p-2 shadow-2xl dark:bg-black">
          <MainToolbarContent onHighlighterClick={onHighlighterClick} onLinkClick={onLinkClick} isMobile={isMobile} />
        </div>
      </BubbleMenu>
    </>
  )
}

export default TextMenu

四、slash command

参考官网的 Demo,基于 @tiptap/suggestion 实现,功能复杂,addProseMirrorPlugins 添加原生的插件

import { Editor, Extension } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import Suggestion, { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'
import { PluginKey } from '@tiptap/pm/state'
import tippy from 'tippy.js'

import { GROUPS } from './groups'
import { MenuList } from './MenuList'

// 扩展名称常量
const extensionName = 'slashCommand'

// 全局弹窗实例,用于显示命令菜单
let popup: any

/**
 * SlashCommand 扩展
 * 实现类似 Notion 的斜杠命令功能,用户输入 "/" 后可以快速插入各种内容块
 */
export const SlashCommand = Extension.create({
  name: extensionName,

  // 设置较高的优先级,确保在其他扩展之前加载
  priority: 200,

  /**
   * 扩展创建时的初始化
   * 创建 tippy.js 弹窗实例,用于显示命令菜单
   */
  onCreate() {
    popup = tippy('body', {
      interactive: true, // 允许用户与弹窗交互
      trigger: 'manual', // 手动控制显示/隐藏
      placement: 'bottom-start', // 弹窗位置:下方左对齐
      theme: 'slash-command', // 自定义主题
      maxWidth: '16rem', // 最大宽度
      offset: [16, 8], // 偏移量 [垂直, 水平]
      popperOptions: {
        strategy: 'fixed', // 使用 fixed 定位策略
        modifiers: [
          {
            name: 'flip', // 禁用自动翻转,保持固定位置
            enabled: false
          }
        ]
      }
    })
  },

  /**
   * 添加 ProseMirror 插件
   * 使用 Suggestion 插件实现斜杠命令功能
   */
  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        char: '/', // 触发字符
        allowSpaces: true, // 允许空格
        startOfLine: true, // 必须在行首
        pluginKey: new PluginKey(extensionName),

        /**
         * 判断是否允许显示建议菜单
         * @param state - 编辑器状态
         * @param range - 选择范围
         * @returns 是否允许显示菜单
         */
        allow: ({ state, range }) => {
          const $from = state.doc.resolve(range.from)
          const isRootDepth = $from.depth === 1 // 在根层级
          const isParagraph = $from.parent.type.name === 'paragraph' // 在段落中
          const isStartOfNode = $from.parent.textContent?.charAt(0) === '/' // 以 "/" 开头
          // TODO: 列布局支持
          const isInColumn = this.editor.isActive('column') // 在列布局中

          // 检查 "/" 后的内容是否有效(不能以双空格结尾)
          const afterContent = $from.parent.textContent?.substring($from.parent.textContent?.indexOf('/'))
          const isValidAfterContent = !afterContent?.endsWith('  ')

          return (
            ((isRootDepth && isParagraph && isStartOfNode) || (isInColumn && isParagraph && isStartOfNode)) &&
            isValidAfterContent
          )
        },
        /**
         * 执行选中的命令
         * 删除斜杠命令文本,然后执行对应的动作
         * @param editor - 编辑器实例
         * @param props - 命令属性
         */
        command: ({ editor, props }: { editor: Editor; props: any }) => {
          const { view, state } = editor
          const { $head, $from } = view.state.selection

          const end = $from.pos
          // 计算斜杠命令的起始位置
          const from = $head?.nodeBefore
            ? end - ($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ?? 0)
            : $from.start()

          // 删除斜杠命令文本
          const tr = state.tr.deleteRange(from, end)
          view.dispatch(tr)

          // 执行命令对应的动作
          props.action(editor)
          view.focus()
        },
        /**
         * 根据查询字符串过滤和返回命令项
         * @param query - 用户输入的查询字符串
         * @returns 过滤后的命令组
         */
        items: ({ query }: { query: string }) => {
          // 根据查询字符串过滤命令
          const withFilteredCommands = GROUPS.map((group) => ({
            ...group,
            commands: group.commands
              .filter((item) => {
                const labelNormalized = item.label.toLowerCase().trim()
                const queryNormalized = query.toLowerCase().trim()

                // 支持别名搜索
                if (item.aliases) {
                  const aliases = item.aliases.map((alias) => alias.toLowerCase().trim())
                  return labelNormalized.includes(queryNormalized) || aliases.includes(queryNormalized)
                }

                return labelNormalized.includes(queryNormalized)
              })
              // 过滤掉应该隐藏的命令
              .filter((command) => (command.shouldBeHidden ? !command.shouldBeHidden(this.editor) : true))
          }))

          // 移除空的命令组
          const withoutEmptyGroups = withFilteredCommands.filter((group) => {
            return group.commands.length > 0
          })

          // 为所有命令设置启用状态
          const withEnabledSettings = withoutEmptyGroups.map((group) => ({
            ...group,
            commands: group.commands.map((command) => ({
              ...command,
              isEnabled: true
            }))
          }))

          return withEnabledSettings
        },
        /**
         * 渲染建议菜单
         * 返回生命周期方法对象,处理菜单的显示、更新、隐藏等
         */
        render: () => {
          let component: any
          let scrollHandler: (() => void) | null = null

          return {
            /**
             * 菜单开始显示时的处理
             * @param props - 建议属性
             */
            onStart: (props: SuggestionProps) => {
              // 创建 React 组件渲染器
              component = new ReactRenderer(MenuList, {
                props,
                editor: props.editor
              })

              const { view } = props.editor

              /**
               * 获取参考元素的客户端矩形
               * 用于定位弹窗位置
               */
              const getReferenceClientRect = () => {
                if (!props.clientRect) {
                  // 防止类型报错: 显式指定 any
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                  // @ts-expect-error
                  return props.editor.storage[extensionName]?.rect
                }

                const rect = props.clientRect()

                if (!rect) {
                  // 尝试安全访问已缓存的 rect,避免类型报错
                  // const storage: Record<string, any> = props.editor.storage as Record<string, any>
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                  // @ts-expect-error
                  return storage[extensionName]?.rect ?? new DOMRect(0, 0, 0, 0)
                }

                let yPos = rect.y

                // 如果菜单会超出屏幕底部,则向上调整位置
                if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
                  const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
                  yPos = rect.y - diff
                }

                return new DOMRect(rect.x, yPos, rect.width, rect.height)
              }

              // 滚动时更新弹窗位置
              scrollHandler = () => {
                popup?.[0].setProps({
                  getReferenceClientRect
                })
              }

              // 监听滚动事件
              view.dom.parentElement?.addEventListener('scroll', scrollHandler)

              // 配置并显示弹窗
              popup?.[0].setProps({
                getReferenceClientRect,
                appendTo: () => document.body,
                content: component.element
              })

              popup?.[0].show()
            },

            /**
             * 菜单更新时的处理
             * @param props - 建议属性
             */
            onUpdate(props: SuggestionProps) {
              // 更新组件属性
              component.updateProps(props)

              const { view } = props.editor

              /**
               * 获取参考元素的客户端矩形
               * 与 onStart 中的逻辑相同
               */
              const getReferenceClientRect = () => {
                if (!props.clientRect) {
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                  // @ts-expect-error
                  return props.editor.storage[extensionName].rect
                }

                const rect = props.clientRect()

                if (!rect) {
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                  // @ts-expect-error
                  return props.editor.storage[extensionName].rect
                }

                let yPos = rect.y

                // 防止菜单超出屏幕底部
                if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
                  const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
                  yPos = rect.y - diff
                }

                return new DOMRect(rect.x, yPos, rect.width, rect.height)
              }

              // 滚动处理函数
              const scrollHandler = () => {
                popup?.[0].setProps({
                  getReferenceClientRect
                })
              }

              view.dom.parentElement?.addEventListener('scroll', scrollHandler)

              // 更新存储的矩形信息

              // eslint-disable-next-line @typescript-eslint/ban-ts-comment
              // @ts-expect-error
              props.editor.storage[extensionName].rect = props.clientRect
                ? getReferenceClientRect()
                : {
                    width: 0,
                    height: 0,
                    left: 0,
                    top: 0,
                    right: 0,
                    bottom: 0
                  }

              // 更新弹窗属性
              popup?.[0].setProps({
                getReferenceClientRect
              })
            },

            /**
             * 键盘事件处理
             * @param props - 键盘事件属性
             * @returns 是否阻止默认行为
             */
            onKeyDown(props: SuggestionKeyDownProps) {
              // ESC 键隐藏菜单
              if (props.event.key === 'Escape') {
                popup?.[0].hide()
                return true
              }

              // 如果菜单未显示,则显示它
              if (!popup?.[0].state.isShown) {
                popup?.[0].show()
              }

              // 将键盘事件传递给组件处理
              return component.ref?.onKeyDown(props)
            },

            /**
             * 菜单退出时的清理工作
             * @param props - 建议属性
             */
            onExit(props) {
              // 隐藏弹窗
              popup?.[0].hide()

              // 移除滚动事件监听器
              if (scrollHandler) {
                const { view } = props.editor
                view.dom.parentElement?.removeEventListener('scroll', scrollHandler)
              }

              // 销毁组件
              component.destroy()
            }
          }
        }
      })
    ]
  },

  /**
   * 添加扩展存储
   * 用于存储弹窗位置信息
   */
  addStorage() {
    return {
      rect: {
        width: 0,
        height: 0,
        left: 0,
        top: 0,
        right: 0,
        bottom: 0
      }
    }
  }
})

export default SlashCommand

五、总结

  • 前言:TipTap 编辑器 Notion 风格的时候,需要选中文本提供菜单栏,而不是固定在页面,是动态的,然后支持输入 / 唤醒垂直菜单栏,这些都是 simple tiptap 没有的,需要自己实现。
  • 基础 Demo:编辑器组件只负责创建 Editor 实例、注册扩展和承载正文,菜单状态交给 MenuBar。
  • slash command:ProseMirror 指南
  • 抽离组件:编辑器组件只负责创建 Editor 实例、注册扩展和承载正文,菜单状态交给 MenuBar。
  • 基础按钮组件:按钮是否高亮来自 editor.isActive(),能否执行应由命令的 can() 结果决定;
  • 菜单栏组件:菜单栏通过 editor.chain().focus().toggleXxx().run() 串起“恢复选区焦点、切换 Mark、提交事务”。

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“菜单栏和 slash command”中,需要同时满足“前言”与“抽离组件”。给定正文约束“TipTap 编辑器 Notion 风格的时候,需要选中文本提供菜单栏,而不是固定在页面,是动态的,然后支持输入 / 唤醒垂直菜单栏,这些都是 simple tiptap 没有的,需要自己实现。”,哪些判断保持了原有处理机制?多选
2“菜单栏和 slash command”出现偏差:“在“菜单栏和 slash command / 基础按钮组件”中,即使不满足“按钮是否高亮来自 editor.isActive(),能否执行应由命令的 can() 结果决定”,结果与副作用仍会保持不变。”已成为实际行为。围绕“基础按钮组件”与“菜单栏组件”,哪些判断能定位被改变的职责或边界?多选
3评审“菜单栏和 slash command”方案时,验收条件包含“参考官网的 Demo,基于 @tiptap/suggestion 实现,功能复杂,addProseMirrorPlugins 添加原生的插件”。关于“slash command”与“基于 simple tiptap 的时候,可以把固定的菜单栏引入 @tiptap/extension-bubble-menu 扩展里”的哪些决策符合正文机制?多选