字
字节笔记本
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-websockettsx
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>
)
}相关文章
- VibeAny 部署到 Cloudflare Workers 完全指南 - 了解完整的部署流程
- SSR Stubs 技术详解 - 深入了解 SSR stubs 优化原理
- Shiki 代码高亮完全指南 - 代码高亮的配置和使用
- Mermaid 图表绘制完全指南 - 图表绘制的完整教程
分享: