浮动元素

一个浮动的 UI 元素,会根据当前 Tiptap 编辑器中的选区位置自行定位。用于浮动工具栏、菜单及其它需出现在文本光标附近的 UI 元素,具备智能定位和交互处理功能。

安装

通过 Tiptap CLI 添加该组件:

npx @tiptap/cli@latest add floating-element

组件

<FloatingElement />

一个多功能 React 组件,可创建相对于 Tiptap 编辑器文本选区定位的浮动 UI 元素。

使用示例

import * as React from 'react'
import { EditorContent, EditorContext, useEditor } from '@tiptap/react'

// --- Tiptap 核心扩展 ---
import { StarterKit } from '@tiptap/starter-kit'

// --- Tiptap UI ---
import { FloatingElement } from '@/components/tiptap-ui-utils/floating-element'
import { MarkButton } from '@/components/tiptap-ui/mark-button'

// --- UI 原语 ---
import { ButtonGroup } from '@/components/tiptap-ui-primitive/button'
import { Toolbar } from '@/components/tiptap-ui-primitive/toolbar'

// --- Tiptap 节点 ---
import '@/components/tiptap-node/paragraph-node/paragraph-node.scss'

export const FloatingElementExample = () => {
  const editor = useEditor({
    immediatelyRender: false,
    content: `<h2>浮动元素示例</h2>
      <p>尝试选中编辑器中的一些文字。一个简单的格式工具栏将出现在你的选区上方。
      FloatingElement 组件会使 UI 元素根据文本选区或光标位置定位。
      它常用于上下文工具栏、菜单以及其他应在当前编辑上下文附近显示的元素。</p>`,
    extensions: [StarterKit],
  })

  return (
    <EditorContext.Provider value={{ editor }}>
      <EditorContent editor={editor} role="presentation" />

      <FloatingElement editor={editor}>
        <Toolbar variant="floating">
          <ButtonGroup orientation="horizontal">
            <MarkButton type="bold" />
            <MarkButton type="italic" />
          </ButtonGroup>
        </Toolbar>
      </FloatingElement>
    </EditorContext.Provider>
  )
}

属性说明

名称类型默认值说明
editorEditor | nullundefined需绑定的 Tiptap 编辑器实例
shouldShowbooleanundefined是否显示浮动元素的控制开关
floatingOptionsPartial<UseFloatingOptions>undefined传递给浮动 UI 的额外配置项
zIndexnumber50浮动元素的 z-index
onOpenChange(open: boolean) => voidundefined显示状态变化时触发的回调
referenceElementHTMLElement | nullundefined定位用的参考元素,若提供则优先于 getBoundingClientRect
getBoundingClientRect(editor: Editor) => DOMRect | nullgetSelectionBoundingRect自定义获取浮动元素定位的函数,仅在未提供 referenceElement 时生效
closeOnEscapebooleantrue是否在按下 Esc 键时关闭浮动元素
resetTextSelectionOnClosebooleantrue当为 true(默认)时,浮动元素关闭后会重置/清除编辑器的文本选区;为 false 则保留选区
childrenReact.ReactNodeundefined浮动元素内部显示的内容

高级使用示例

基础浮动工具栏

import { shift, flip, offset } from '@floating-ui/react'
import { FloatingElement } from '@/components/tiptap-ui-utils/floating-element'

function FloatingToolbar({ editor }) {
  return (
    <FloatingElement
      editor={editor}
      floatingOptions={{
        placement: 'top',
        middleware: [shift(), flip(), offset(8)],
      }}
    >
      {/* 浮动内容 */}
    </FloatingElement>
  )
}

支持移动端的自定义定位

import { FloatingElement } from '@/components/tiptap-ui-utils/floating-element'
import { useMobile } from '@/hooks/use-mobile'

function ResponsiveFloatingMenu({ editor, isMenuVisible }) {
  const isMobile = useMobile()

  const getCustomRect = (editor) => {
    // 自定义定位逻辑
    // 示例:相对于当前光标定位
    return editor.view.coordsAtPos(editor.state.selection.from)
  }

  return (
    <FloatingElement
      editor={editor}
      shouldShow={isMenuVisible}
      getBoundingClientRect={getCustomRect}
      {...(isMobile
        ? {
            style: {
              position: 'fixed',
              left: 0,
              right: 0,
              bottom: 0,
              margin: '.5rem',
              zIndex: 50,
            },
          }
        : {})}
    >
      {/* 浮动内容 */}
    </FloatingElement>
  )
}

自定义 shouldShow 浮动菜单

import { useState, useEffect } from 'react'
import { FloatingElement } from '@/components/tiptap-ui-utils/floating-element'
import { isSelectionValid } from '@/lib/tiptap-collab-utils'

function SelectionMenu({ editor }) {
  const [isVisible, setIsVisible] = useState(false)

  useEffect(() => {
    if (!editor) return

    const updateVisibility = () => {
      const hasSelection = !editor.state.selection.empty
      const isValidSelection = isSelectionValid(editor)
      setIsVisible(hasSelection && isValidSelection)
    }

    editor.on('selectionUpdate', updateVisibility)
    return () => editor.off('selectionUpdate', updateVisibility)
  }, [editor])

  return (
    <FloatingElement editor={editor} shouldShow={isVisible}>
      {/* 你的浮动内容 */}
    </FloatingElement>
  )
}

使用参考元素定位

将浮动元素绑定到某个特定 DOM 元素,而不是文本选区:

import { useState } from 'react'
import { offset, flip, shift } from '@floating-ui/react'
import { FloatingElement } from '@/components/tiptap-ui-utils/floating-element'

function ButtonWithTooltip() {
  const [buttonRef, setButtonRef] = useState<HTMLElement | null>(null)
  const [showTooltip, setShowTooltip] = useState(false)

  return (
    <>
      <button
        ref={setButtonRef}
        onMouseEnter={() => setShowTooltip(true)}
        onMouseLeave={() => setShowTooltip(false)}
      >
        悬停我
      </button>

      <FloatingElement
        editor={editor}
        referenceElement={buttonRef}
        shouldShow={showTooltip}
        floatingOptions={{
          placement: 'top',
          middleware: [offset(8), flip(), shift()],
        }}
      >
        <div className="tooltip">有用的提示内容</div>
      </FloatingElement>
    </>
  )
}

工具函数

getSelectionBoundingRect(editor)

获取编辑器中当前选区的边界矩形。

参数:

  • editor - Tiptap 编辑器实例

返回: DOMRect \| null - 当前选区的边界矩形

import { getSelectionBoundingRect } from '@/lib/tiptap-collab-utils'

const rect = getSelectionBoundingRect(editor)
console.log('选区边界:', rect)

isSelectionValid(editor, selection?, excludedNodeTypes?)

检查当前选区是否适合显示浮动元素。对空选区、代码块、排除的节点类型及表格单元格返回 false

参数:

  • editor - Tiptap 编辑器实例
  • selection(可选)- 待验证的选区,默认使用 editor.state.selection
  • excludedNodeTypes(可选)- 需要排除的节点类型数组,默认 ['imageUpload', 'horizontalRule']

返回: boolean - 选区是否适合显示浮动元素

import { isSelectionValid } from '@/lib/tiptap-collab-utils'

const shouldShow = isSelectionValid(editor)

// 使用自定义排除节点类型
const isValid = isSelectionValid(editor, undefined, ['image', 'video'])

isTextSelectionValid(editor)

检查当前文本选区是否适合编辑。对空选区、代码块和节点选区返回 false

参数:

  • editor - Tiptap 编辑器实例

返回: boolean - 文本选区是否有效

import { isTextSelectionValid } from '@/lib/tiptap-collab-utils'

const canEdit = isTextSelectionValid(editor)
if (canEdit) {
  // 显示文本编辑工具栏
}

isElementWithinEditor(editor, element)

检查某个 DOM 元素是否位于编辑器的 DOM 树内。用于判断点击或聚焦事件的目标。

参数:

  • editor - Tiptap 编辑器实例
  • element - 要检查的 DOM 元素

返回: boolean - 元素是否位于编辑器内

import { isElementWithinEditor } from '@/components/tiptap-ui-utils/floating-element'

const handleClick = (event: MouseEvent) => {
  if (isElementWithinEditor(editor, event.target as Node)) {
    console.log('点击发生在编辑器内')
  }
}