ByteNoteByteNote

字节笔记本

2026年2月20日

Tiptap 富文本编辑器完全指南:从基础到协作编辑

API中转
¥120

Tiptap 是一个基于 ProseMirror 的无头富文本编辑器框架,提供完全可定制的编辑体验。本文详细介绍在 VibeAny 项目中使用 Tiptap 的各种配置、扩展开发和最佳实践。

什么是 Tiptap

Tiptap 是一个无头(Headless)富文本编辑器,核心优势:

特性说明
无头设计完全控制 UI,不受预设样式限制
模块化通过扩展系统按需添加功能
TypeScript原生 TS 支持,类型安全
协作编辑原生支持 Yjs 实时协作
Markdown支持 Markdown 输入输出
移动端完美支持触摸设备

基础使用

安装

bash
# 核心包
pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit

# 常用扩展
pnpm add @tiptap/extension-link
pnpm add @tiptap/extension-image
pnpm add @tiptap/extension-placeholder
pnpm add @tiptap/extension-underline
pnpm add @tiptap/extension-text-align
pnpm add @tiptap/extension-highlight
pnpm add @tiptap/extension-typography
pnpm add @tiptap/extension-color
pnpm add @tiptap/extension-text-style

基础编辑器

tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

export function BasicEditor() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello Tiptap!</p>',
  })

  return <EditorContent editor={editor} />
}

带工具栏的编辑器

tsx
'use client'

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Bold from '@tiptap/extension-bold'
import Italic from '@tiptap/extension-italic'

export function EditorWithToolbar() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>开始编辑...</p>',
  })

  if (!editor) return null

  return (
    <div className="border rounded-lg">
      {/* 工具栏 */}
      <div className="border-b p-2 flex gap-2">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'bg-gray-200' : ''}
        >
          粗体
        </button>
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'bg-gray-200' : ''}
        >
          斜体
        </button>
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'bg-gray-200' : ''}
        >
          H1
        </button>
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'bg-gray-200' : ''}
        >
          列表
        </button>
      </div>
      
      {/* 编辑区域 */}
      <EditorContent 
        editor={editor} 
        className="p-4 min-h-[200px]"
      />
    </div>
  )
}

扩展详解

Starter Kit 包含的扩展

typescript
import StarterKit from '@tiptap/starter-kit'

// StarterKit 包含以下扩展:
const extensions = [
  'bold',        // 粗体
  'italic',      // 斜体
  'strike',      // 删除线
  'code',        // 行内代码
  'codeBlock',   // 代码块
  'heading',     // 标题 H1-H6
  'bulletList',  // 无序列表
  'orderedList', // 有序列表
  'listItem',    // 列表项
  'blockquote',  // 引用块
  'horizontalRule', // 分割线
  'hardBreak',   // 强制换行
  'dropcursor',  // 拖拽光标
  'gapcursor',   // 间隙光标
  'history',     // 撤销/重做
]

常用扩展配置

tsx
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Highlight from '@tiptap/extension-highlight'

const editor = useEditor({
  extensions: [
    StarterKit,
    
    // 链接
    Link.configure({
      openOnClick: false,
      linkOnPaste: true,
      HTMLAttributes: {
        class: 'text-blue-500 underline',
      },
    }),
    
    // 图片
    Image.configure({
      allowBase64: true,
      HTMLAttributes: {
        class: 'rounded-lg max-w-full',
      },
    }),
    
    // 占位符
    Placeholder.configure({
      placeholder: '开始输入内容...',
    }),
    
    // 下划线
    Underline,
    
    // 文本对齐
    TextAlign.configure({
      types: ['heading', 'paragraph'],
      alignments: ['left', 'center', 'right', 'justify'],
    }),
    
    // 高亮
    Highlight.configure({
      multicolor: true,
    }),
  ],
})

颜色和字体样式

tsx
import { Color } from '@tiptap/extension-color'
import TextStyle from '@tiptap/extension-text-style'
import { EditorContent, useEditor } from '@tiptap/react'

function ColorPicker() {
  const editor = useEditor({
    extensions: [
      StarterKit,
      TextStyle,
      Color,
    ],
  })

  const colors = [
    '#000000', '#ef4444', '#f97316', '#f59e0b',
    '#84cc16', '#10b981', '#06b6d4', '#3b82f6',
    '#8b5cf6', '#d946ef', '#f43f5e',
  ]

  return (
    <div>
      <div className="flex gap-1 mb-2">
        {colors.map((color) => (
          <button
            key={color}
            onClick={() => editor?.chain().focus().setColor(color).run()}
            className="w-6 h-6 rounded"
            style={{ backgroundColor: color }}
          />
        ))}
        <button
          onClick={() => editor?.chain().focus().unsetColor().run()}
          className="w-6 h-6 rounded border"
        >
          ✕
        </button>
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}

自定义扩展

创建自定义节点

typescript
// extensions/Callout.ts
import { Node, mergeAttributes } from '@tiptap/core'

export interface CalloutOptions {
  HTMLAttributes: Record<string, any>
}

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    callout: {
      setCallout: (attributes?: { type: string }) => ReturnType
      toggleCallout: (attributes?: { type: string }) => ReturnType
    }
  }
}

export const Callout = Node.create<CalloutOptions>({
  name: 'callout',
  
  group: 'block',
  content: 'block+',
  
  addAttributes() {
    return {
      type: {
        default: 'info',
        parseHTML: (element) => element.getAttribute('data-type'),
        renderHTML: (attributes) => ({
          'data-type': attributes.type,
        }),
      },
    }
  },
  
  parseHTML() {
    return [
      {
        tag: 'div[data-type="callout"]',
      },
    ]
  },
  
  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(
        { 'data-type': 'callout' },
        this.options.HTMLAttributes,
        HTMLAttributes
      ),
      0,
    ]
  },
  
  addCommands() {
    return {
      setCallout:
        (attributes) =>
        ({ commands }) => {
          return commands.wrapIn(this.name, attributes)
        },
      toggleCallout:
        (attributes) =>
        ({ commands }) => {
          return commands.toggleWrap(this.name, attributes)
        },
    }
  },
})

创建自定义标记

typescript
// extensions/FontSize.ts
import { Mark } from '@tiptap/core'

export const FontSize = Mark.create({
  name: 'fontSize',
  
  addAttributes() {
    return {
      size: {
        default: null,
        parseHTML: (element) => element.style.fontSize,
        renderHTML: (attributes) => {
          if (!attributes.size) return {}
          return {
            style: `font-size: ${attributes.size}`,
          }
        },
      },
    }
  },
  
  parseHTML() {
    return [
      {
        style: 'font-size',
      },
    ]
  },
  
  renderHTML({ HTMLAttributes }) {
    return ['span', HTMLAttributes, 0]
  },
  
  addCommands() {
    return {
      setFontSize:
        (size) =>
        ({ chain }) => {
          return chain().setMark(this.name, { size }).run()
        },
      unsetFontSize:
        () =>
        ({ chain }) => {
          return chain().unsetMark(this.name).run()
        },
    }
  },
})

气泡菜单和浮动菜单

气泡菜单(选中文本时显示)

tsx
import { BubbleMenu } from '@tiptap/react'

function EditorWithBubbleMenu() {
  const editor = useEditor({
    extensions: [StarterKit],
  })

  if (!editor) return null

  return (
    <>
      <EditorContent editor={editor} />
      
      <BubbleMenu 
        editor={editor} 
        tippyOptions={{ duration: 100 }}
        className="bg-white shadow-lg rounded-lg p-2 flex gap-1"
      >
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'bg-gray-200' : ''}
        >
          粗体
        </button>
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'bg-gray-200' : ''}
        >
          斜体
        </button>
        <button
          onClick={() => editor.chain().focus().toggleHighlight().run()}
          className={editor.isActive('highlight') ? 'bg-gray-200' : ''}
        >
          高亮
        </button>
      </BubbleMenu>
    </>
  )
}

浮动菜单(空行时显示)

tsx
import { FloatingMenu } from '@tiptap/react'

function EditorWithFloatingMenu() {
  const editor = useEditor({
    extensions: [StarterKit],
  })

  if (!editor) return null

  return (
    <>
      <EditorContent editor={editor} />
      
      <FloatingMenu 
        editor={editor}
        className="bg-white shadow-lg rounded-lg p-2 flex gap-1"
      >
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
        >
          H1
        </button>
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
        >
          列表
        </button>
        <button
          onClick={() => {
            const url = window.prompt('图片 URL')
            if (url) {
              editor.chain().focus().setImage({ src: url }).run()
            }
          }}
        >
          图片
        </button>
      </FloatingMenu>
    </>
  )
}

Markdown 支持

Markdown 输入输出

tsx
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from 'tiptap-markdown'

function MarkdownEditor() {
  const [markdown, setMarkdown] = useState('')
  
  const editor = useEditor({
    extensions: [
      StarterKit,
      Markdown.configure({
        html: false,
        tightLists: true,
        tightListClass: 'tight',
        bulletListMarker: '-',
        linkify: false,
        breaks: false,
        transformPastedText: true,
        transformCopiedText: false,
      }),
    ],
    content: markdown,
    onUpdate: ({ editor }) => {
      // 获取 Markdown 格式
      setMarkdown(editor.storage.markdown.getMarkdown())
    },
  })

  return (
    <div className="grid grid-cols-2 gap-4">
      <div>
        <h3>编辑器</h3>
        <EditorContent editor={editor} className="border p-4" />
      </div>
      <div>
        <h3>Markdown 预览</h3>
        <pre className="border p-4 bg-gray-50">{markdown}</pre>
      </div>
    </div>
  )
}

协作编辑

Yjs 集成

bash
pnpm add @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
pnpm add yjs y-websocket
tsx
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'

function CollaborativeEditor() {
  const [provider, setProvider] = useState<WebsocketProvider | null>(null)
  
  useEffect(() => {
    const ydoc = new Y.Doc()
    const wsProvider = new WebsocketProvider(
      'wss://your-websocket-server.com',
      'room-name',
      ydoc
    )
    
    setProvider(wsProvider)
    
    return () => {
      wsProvider.destroy()
    }
  }, [])

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        history: false, // 禁用本地历史,使用协作历史
      }),
      Collaboration.configure({
        document: provider?.doc,
      }),
      CollaborationCursor.configure({
        provider: provider,
        user: {
          name: '用户名称',
          color: '#f783ac',
        },
      }),
    ],
  })

  return <EditorContent editor={editor} />
}

Cloudflare Workers 中的 SSR Stubs

问题

Tiptap 依赖浏览器 DOM API,在服务端渲染时会报错:

Error: window is not defined

解决方案

typescript
// vite.config.ts
const ssrOnlyStubs = () => ({
  name: 'ssr-only-stubs',
  enforce: 'pre',
  resolveId(id, importer, { ssr }) {
    if (!ssr) return null
    
    // Tiptap 及其依赖
    if (id.includes('@tiptap/') || 
        id.includes('prosemirror-') ||
        id.includes('yjs') ||
        id.includes('y-websocket')) {
      return '\0stub:tiptap'
    }
  },
  load(id) {
    if (id === '\0stub:tiptap') {
      return `
        export const useEditor = () => ({})
        export const EditorContent = () => null
        export const BubbleMenu = () => null
        export const FloatingMenu = () => null
        export default {}
      `
    }
  }
})

客户端动态加载

tsx
'use client'

import { useEffect, useState } from 'react'
import type { Editor } from '@tiptap/react'

interface RichTextEditorProps {
  content?: string
  onChange?: (html: string) => void
}

export function RichTextEditor({ content, onChange }: RichTextEditorProps) {
  const [editor, setEditor] = useState<Editor | null>(null)

  useEffect(() => {
    // 动态导入 Tiptap
    Promise.all([
      import('@tiptap/react'),
      import('@tiptap/starter-kit'),
    ]).then(([{ useEditor, EditorContent }, { default: StarterKit }]) => {
      const editorInstance = useEditor({
        extensions: [StarterKit],
        content,
        onUpdate: ({ editor }) => {
          onChange?.(editor.getHTML())
        },
      })
      
      setEditor(editorInstance)
    })
  }, [])

  if (!editor) {
    return <div className="border p-4">加载编辑器...</div>
  }

  return <EditorContent editor={editor} />
}

完整编辑器示例

tsx
'use client'

import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Highlight from '@tiptap/extension-highlight'
import { 
  Bold, Italic, Underline as UnderlineIcon, 
  Strikethrough, Heading1, Heading2, 
  List, ListOrdered, Quote, Code, 
  Undo, Redo, Link as LinkIcon, Image as ImageIcon,
  AlignLeft, AlignCenter, AlignRight, Highlighter
} from 'lucide-react'

interface RichTextEditorProps {
  content?: string
  onChange?: (html: string) => void
  placeholder?: string
}

export function FullFeaturedEditor({ 
  content = '', 
  onChange,
  placeholder = '开始输入内容...'
}: RichTextEditorProps) {
  const editor = useEditor({
    extensions: [
      StarterKit,
      Link.configure({
        openOnClick: false,
        HTMLAttributes: {
          class: 'text-blue-500 underline',
        },
      }),
      Image.configure({
        allowBase64: true,
        HTMLAttributes: {
          class: 'rounded-lg max-w-full',
        },
      }),
      Placeholder.configure({ placeholder }),
      Underline,
      TextAlign.configure({
        types: ['heading', 'paragraph'],
      }),
      Highlight.configure({ multicolor: true }),
    ],
    content,
    onUpdate: ({ editor }) => {
      onChange?.(editor.getHTML())
    },
  })

  if (!editor) return null

  const ToolbarButton = ({ 
    onClick, 
    isActive, 
    icon: Icon,
    title 
  }: { 
    onClick: () => void
    isActive?: boolean
    icon: React.ComponentType<{ size?: number }>
    title: string
  }) => (
    <button
      onClick={onClick}
      title={title}
      className={\`p-2 rounded hover:bg-gray-100 transition-colors \${
        isActive ? 'bg-gray-200 text-blue-600' : 'text-gray-600'
      }\`}
    >
      <Icon size={18} />
    </button>
  )

  const addLink = () => {
    const url = window.prompt('输入链接 URL')
    if (url) {
      editor.chain().focus().setLink({ href: url }).run()
    }
  }

  const addImage = () => {
    const url = window.prompt('输入图片 URL')
    if (url) {
      editor.chain().focus().setImage({ src: url }).run()
    }
  }

  return (
    <div className="border rounded-lg overflow-hidden">
      {/* 主工具栏 */}
      <div className="border-b bg-gray-50 p-2 flex flex-wrap gap-1">
        <div className="flex gap-1">
          <ToolbarButton
            onClick={() => editor.chain().focus().undo().run()}
            icon={Undo}
            title="撤销"
          />
          <ToolbarButton
            onClick={() => editor.chain().focus().redo().run()}
            icon={Redo}
            title="重做"
          />
        </div>
        
        <div className="w-px h-6 bg-gray-300 mx-1" />
        
        <div className="flex gap-1">
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleBold().run()}
            isActive={editor.isActive('bold')}
            icon={Bold}
            title="粗体"
          />
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleItalic().run()}
            isActive={editor.isActive('italic')}
            icon={Italic}
            title="斜体"
          />
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleUnderline().run()}
            isActive={editor.isActive('underline')}
            icon={UnderlineIcon}
            title="下划线"
          />
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleStrike().run()}
            isActive={editor.isActive('strike')}
            icon={Strikethrough}
            title="删除线"
          />
        </div>
        
        <div className="w-px h-6 bg-gray-300 mx-1" />
        
        <div className="flex gap-1">
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
            isActive={editor.isActive('heading', { level: 1 })}
            icon={Heading1}
            title="标题 1"
          />
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
            isActive={editor.isActive('heading', { level: 2 })}
            icon={Heading2}
            title="标题 2"
          />
        </div>
        
        <div className="w-px h-6 bg-gray-300 mx-1" />
        
        <div className="flex gap-1">
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleBulletList().run()}
            isActive={editor.isActive('bulletList')}
            icon={List}
            title="无序列表"
          />
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleOrderedList().run()}
            isActive={editor.isActive('orderedList')}
            icon={ListOrdered}
            title="有序列表"
          />
        </div>
        
        <div className="w-px h-6 bg-gray-300 mx-1" />
        
        <div className="flex gap-1">
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleBlockquote().run()}
            isActive={editor.isActive('blockquote')}
            icon={Quote}
            title="引用"
          />
          <ToolbarButton
            onClick={() => editor.chain().focus().toggleCodeBlock().run()}
            isActive={editor.isActive('codeBlock')}
            icon={Code}
            title="代码块"
          />
        </div>
        
        <div className="w-px h-6 bg-gray-300 mx-1" />
        
        <div className="flex gap-1">
          <ToolbarButton
            onClick={addLink}
            isActive={editor.isActive('link')}
            icon={LinkIcon}
            title="插入链接"
          />
          <ToolbarButton
            onClick={addImage}
            icon={ImageIcon}
            title="插入图片"
          />
        </div>
      </div>
      
      {/* 编辑区域 */}
      <EditorContent 
        editor={editor} 
        className="p-4 min-h-[300px] prose max-w-none"
      />
      
      {/* 字数统计 */}
      <div className="border-t bg-gray-50 px-4 py-2 text-sm text-gray-500">
        {editor.storage.characterCount?.characters() || 0} 字符
      </div>
    </div>
  )
}

相关文章

分享: