Tiptap 编辑器实现 Markdown 格式保存

1/8/2026
8 min read
325
ReactTiptapRichText

为 Tiptap 编辑器实现 Markdown 格式保存,这里有几个完整的解决方案:

📋 方案对比

方案优点缺点适用场景
prosemirror-markdown官方维护,稳定配置稍复杂生产环境
tiptap-markdownTiptap 专用,简单功能较少快速实现
自定义转换完全可控开发量大特殊需求

🚀 推荐方案:使用 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...