Tiptap 编辑器实现 Markdown 格式保存
1/8/2026
8 min read
325
ReactTiptapRichText
为 Tiptap 编辑器实现 Markdown 格式保存,这里有几个完整的解决方案:
📋 方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| prosemirror-markdown | 官方维护,稳定 | 配置稍复杂 | 生产环境 |
| tiptap-markdown | Tiptap 专用,简单 | 功能较少 | 快速实现 |
| 自定义转换 | 完全可控 | 开发量大 | 特殊需求 |
🚀 推荐方案:使用 prosemirror-markdown
1. 安装依赖
bash
npm install prosemirror-markdown
npm install markdown-it # 如果需要解析回编辑器
2. 创建转换工具文件
javascript
// utils/markdownConverter.js
import { schema } from '@tiptap/pm/schema'
import { defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'
// 扩展 Tiptap schema 以支持更多 Markdown 特性
const customSchema = {
...schema,
nodes: {
...schema.spec.nodes,
// 添加对任务列表的支持
task_list: {
group: 'block',
content: 'task_item+',
parseDOM: [{ tag: 'ul[data-type="taskList"]' }],
toDOM: () => ['ul', { 'data-type': 'taskList' }, 0]
},
task_item: {
content: 'paragraph block*',
defining: true,
attrs: {
checked: { default: false }
},
parseDOM: [
{
tag: 'li[data-type="taskItem"]',
getAttrs: dom => ({
checked: dom.getAttribute('data-checked') === 'true'
})
}
],
toDOM: node => [
'li',
{
'data-type': 'taskItem',
'data-checked': node.attrs.checked
},
0
]
}
}
}
// Markdown 序列化器配置
const serializer = defaultMarkdownSerializer
// 解析器配置
const parser = defaultMarkdownParser
/**
* 将 Tiptap 编辑器内容转换为 Markdown
* @param {Object} editor - Tiptap 编辑器实例
* @returns {string} Markdown 字符串
*/
export function toMarkdown(editor) {
try {
const doc = editor.state.doc
const markdown = serializer.serialize(doc, {
tightLists: true // 紧凑列表格式
})
// 后处理:修复一些格式问题
return postProcessMarkdown(markdown)
} catch (error) {
console.error('转换 Markdown 失败:', error)
return ''
}
}
/**
* 将 Markdown 转换为 Tiptap 可以设置的 JSON
* @param {string} markdown - Markdown 字符串
* @returns {Object} Prosemirror 文档节点
*/
export function fromMarkdown(markdown) {
try {
return parser.parse(markdown)
} catch (error) {
console.error('解析 Markdown 失败:', error)
return null
}
}
/**
* 后处理 Markdown,修复格式问题
*/
function postProcessMarkdown(markdown) {
return markdown
// 修复代码块缩进
.replace(/```\n([\s\S]*?)\n```/g, (match, code) => {
return '```\n' + code.replace(/^\s+|\s+$/g, '') + '\n```'
})
// 确保空行之间只有一个换行符
.replace(/\n{3,}/g, '\n\n')
// 修复表格格式
.replace(/\|-\|/g, '| - |')
}
/**
* 下载 Markdown 文件
* @param {string} markdown - Markdown 内容
* @param {string} filename - 文件名(不含扩展名)
*/
export function downloadMarkdown(markdown, filename = 'document') {
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
3. 在 Tiptap 组件中使用
javascript
// components/TiptapEditor.jsx
import React, { useState, useCallback } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import { toMarkdown, downloadMarkdown } from '../utils/markdownConverter'
// Tiptap 工具栏组件
const MenuBar = ({ editor, onSave }) => {
if (!editor) return null
return (
<div className="menu-bar">
<button onClick={() => editor.chain().focus().toggleBold().run()}>
粗体
</button>
<button onClick={() => editor.chain().focus().toggleItalic().run()}>
斜体
</button>
<button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
标题1
</button>
<button onClick={() => editor.chain().focus().toggleBulletList().run()}>
列表
</button>
<button onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
代码块
</button>
<button onClick={onSave} className="save-button">
保存为 Markdown
</button>
</div>
)
}
export default function TiptapEditor({ initialContent, onSave }) {
const [filename, setFilename] = useState('my-document')
const editor = useEditor({
extensions: [
StarterKit.configure({
// 禁用一些与 Markdown 不兼容的格式
strike: false,
code: { HTMLAttributes: { class: 'inline-code' } }
}),
TaskList,
TaskItem.configure({
nested: true,
}),
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
],
content: initialContent,
editorProps: {
attributes: {
class: 'prose prose-lg focus:outline-none min-h-[400px]',
},
},
})
const handleSave = useCallback(() => {
if (!editor) return
try {
// 转换为 Markdown
const markdown = toMarkdown(editor)
// 调用父组件的保存回调
if (onSave) {
onSave(markdown)
} else {
// 默认行为:下载文件
downloadMarkdown(markdown, filename)
}
console.log('文档已保存为 Markdown:', markdown.length, '字符')
} catch (error) {
console.error('保存失败:', error)
alert('保存失败,请重试')
}
}, [editor, filename, onSave])
return (
<div className="editor-container border rounded-lg p-4">
<MenuBar editor={editor} onSave={handleSave} />
<div className="filename-input mb-4">
<label className="block text-sm font-medium mb-1">
文件名:
<input
type="text"
value={filename}
onChange={(e) => setFilename(e.target.value)}
className="ml-2 px-2 py-1 border rounded"
placeholder="输入文件名"
/>
</label>
</div>
<EditorContent editor={editor} />
</div>
)
}
4. 集成到 Next.js 页面中
javascript
// app/page.jsx
'use client'
import { useState } from 'react'
import TiptapEditor from '@/components/TiptapEditor'
import { saveToDatabase } from '@/lib/db'
export default function EditorPage() {
const [lastSaved, setLastSaved] = useState(null)
// 处理保存 - 存储到数据库
const handleSave = async (markdown) => {
try {
// 同时保存原始 JSON 和 Markdown
const documentData = {
markdown,
savedAt: new Date().toISOString(),
version: '1.0'
}
// 保存到数据库
await saveToDatabase(documentData)
// 更新状态
setLastSaved(new Date().toLocaleTimeString())
alert('文档已保存!')
} catch (error) {
console.error('保存到数据库失败:', error)
alert('保存失败')
}
}
// 处理保存 - 上传到服务器
const handleSaveToServer = async (markdown) => {
const response = await fetch('/api/save-document', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: markdown,
format: 'markdown'
})
})
if (response.ok) {
const result = await response.json()
console.log('保存成功:', result)
}
}
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Markdown 编辑器</h1>
<div className="mb-4 p-3 bg-blue-50 rounded">
<p>此编辑器内容可以保存为标准 Markdown 格式,兼容所有 Markdown 编辑器。</p>
{lastSaved && (
<p className="text-sm text-green-600 mt-1">
最后保存时间: {lastSaved}
</p>
)}
</div>
<TiptapEditor
initialContent="<p>开始编辑你的文档...</p>"
onSave={handleSave}
/>
<div className="mt-8 p-4 border-t">
<h2 className="text-lg font-semibold mb-2">为什么保存为 Markdown?</h2>
<ul className="list-disc pl-5 space-y-1">
<li>📄 <strong>通用格式</strong>:任何编辑器都能打开</li>
<li>🔀 <strong>易于迁移</strong>:随时切换到 Obsidian、Typora、VS Code 等</li>
<li>💾 <strong>纯文本存储</strong>:体积小,版本控制友好</li>
<li>🔄 <strong>双向兼容</strong>:可以重新导入回 Tiptap 编辑</li>
</ul>
</div>
</div>
)
}
📁 保存策略建议
方案A:保存到数据库
javascript
// lib/db.js
export async function saveDocument({ content, format = 'markdown', metadata = {} }) {
// 1. 保存原始 JSON(用于 Tiptap 编辑)
// 2. 保存 Markdown(用于导出/迁移)
const document = {
json: content, // 原始 Tiptap JSON
markdown: toMarkdown(content), // 转换后的 Markdown
metadata: {
createdAt: new Date(),
updatedAt: new Date(),
wordCount: content.split(/\s+/).length,
...metadata
}
}
// 保存到 Turso
await prisma.document.create({ data: document })
}
方案B:自动保存到本地存储
javascript
// hooks/useAutoSave.js
import { useEffect } from 'react'
import { debounce } from 'lodash'
export function useAutoSave(editor, filename = 'autosave') {
useEffect(() => {
if (!editor) return
const save = debounce(() => {
const markdown = toMarkdown(editor)
// 保存到 localStorage
localStorage.setItem(`editor-${filename}`, markdown)
// 或 IndexedDB
saveToIndexedDB(filename, markdown)
}, 2000)
// 监听编辑器变化
editor.on('update', save)
return () => {
editor.off('update', save)
save.cancel()
}
}, [editor, filename])
}
方案C:导出到云存储
javascript
// utils/cloudExport.js
export async function exportToCloud(markdown, options = {}) {
const { provider = 'github', filename = 'document.md' } = options
const providers = {
github: async (content) => {
// 保存到 GitHub Gist
const response = await fetch('https://api.github.com/gists', {
method: 'POST',
headers: {
'Authorization': `token ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
files: { [filename]: { content } },
public: false,
description: 'Exported from Tiptap Editor'
})
})
return response.json()
},
notion: async (content) => {
// 保存到 Notion
// 需要 Notion API 集成
},
dropbox: async (content) => {
// 保存到 Dropbox
}
}
return providers[provider](markdown)
}
🔄 双向转换:从 Markdown 恢复
javascript
// utils/markdownRestore.js
import { generateJSON } from '@tiptap/html'
import { fromMarkdown } from './markdownConverter'
import markdownit from 'markdown-it'
/**
* 将 Markdown 转换为 Tiptap 可以加载的 JSON
*/
export function markdownToTiptapJSON(markdown) {
// 方法1:使用 markdown-it 转为 HTML,再转 JSON
const md = markdownit({
html: true,
breaks: true,
linkify: true
})
const html = md.render(markdown)
const json = generateJSON(html, extensions)
// 方法2:直接使用 prosemirror-markdown(更精确)
// const json = fromMarkdown(markdown)
return json
}
// 在组件中加载
function loadMarkdownFile(file) {
const reader = new FileReader()
reader.onload = (e) => {
const markdown = e.target.result
const tiptapJSON = markdownToTiptapJSON(markdown)
editor.commands.setContent(tiptapJSON)
}
reader.readAsText(file)
}
🎨 样式优化
css
/* styles/editor.css */
.menu-bar {
@apply flex flex-wrap gap-2 p-2 border-b bg-gray-50;
}
.menu-bar button {
@apply px-3 py-1 text-sm border rounded hover:bg-gray-100;
}
.save-button {
@apply bg-blue-500 text-white hover:bg-blue-600 ml-auto;
}
.prose {
@apply max-w-none;
}
.prose pre {
@apply bg-gray-900 text-gray-100 p-4 rounded overflow-x-auto;
}
.prose code:not(pre code) {
@apply bg-gray-100 text-red-600 px-1 rounded text-sm;
}
📊 支持的 Markdown 功能对照表
| Tiptap 功能 | Markdown 语法 | 兼容性 |
|---|---|---|
| 标题 | # H1, ## H2 | ✅ 完美 |
| 粗体 | text | ✅ 完美 |
| 斜体 | text | ✅ 完美 |
| 代码块 | code | ✅ 完美 |
| 行内代码 | code | ✅ 完美 |
| 无序列表 | - item | ✅ 完美 |
| 有序列表 | 1. item | ✅ 完美 |
| 任务列表 | - [ ] task | ✅ 需扩展 |
| 表格 | ` | header |
| 引用 | > quote | ✅ 完美 |
| 水平线 | --- | ✅ 完美 |
| 图片 | ✅ 完美 | |
| 链接 | text | ✅ 完美 |
🚀 快速开始模板
bash
# 创建新项目
npx create-next-app@latest markdown-editor --typescript --tailwind
cd markdown-editor
# 安装依赖
npm install @tiptap/react @tiptap/starter-kit prosemirror-markdown markdown-it
# 复制上面的代码文件
# 启动开发
npm run dev
总结:使用 prosemirror-markdown 方案是最稳定和标准的。保存时生成 Markdown 文件,既方便迁移到其他编辑器,又保持了在 Tiptap 中编辑的能力。建议同时保存原始 JSON 和 Markdown 格式,实现最佳兼容性。
Thanks for reading!
Comments
Please sign in to join the conversation.
Loading content...