带有评论的跟踪更改

Paid add-on

了解如何通过结合“修订跟踪”(Tracked Changes)和“评论”(Comments)扩展来构建完整的文档审阅体验。用户可以通过评论线程讨论建议,接受或拒绝建议时会自动与线程状态同步。

框架说明

本指南使用 React,但相同的概念适用于 Vue 以及 Tiptap 支持的任何其他框架。

前提条件

设置编辑器

两个扩展需要一起注册。由于评论需要协作提供者以实现持久化,你还需设置 Yjs 和 TiptapCollabProvider

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Collaboration } from '@tiptap/extension-collaboration'
import { CommentsKit } from '@tiptap-pro/extension-comments'
import { TrackedChanges } from '@tiptap-pro/extension-tracked-changes'
import { TiptapCollabProvider } from '@tiptap-pro/provider'
import { useMemo } from 'react'
import * as Y from 'yjs'

function ReviewEditor({ user }) {
  const { ydoc, provider } = useMemo(() => {
    const doc = new Y.Doc()
    const collabProvider = new TiptapCollabProvider({
      appId: 'your-app-id',
      name: 'your-document-name',
      document: doc,
    })
    return { ydoc: doc, provider: collabProvider }
  }, [])

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        undoRedo: false,
      }),
      Collaboration.configure({
        document: ydoc,
      }),
      CommentsKit.configure({
        provider,
        onClickThread: (threadId) => {
          // 处理 UI 中的线程选择
        },
      }),
      TrackedChanges.configure({
        enabled: true,
        userId: user.id,
        userMetadata: {
          name: user.name,
        },
      }),
    ],
  })

  if (!editor) {
    return null
  }

  return <EditorContent editor={editor} />
}

撤销/重做

使用协作时,请关闭 StarterKit 中内置的 undoRedo。在协作环境中,撤销/重做由 Yjs 原生处理。

订阅评论线程

使用评论扩展的 subscribeToThreads 钩子,跟踪线程状态变化以保持 UI 同步。

import { subscribeToThreads } from '@tiptap-pro/extension-comments'
import { useState, useEffect } from 'react'

function useThreads(provider) {
  const [threads, setThreads] = useState([])

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

    const unsubscribe = subscribeToThreads({
      provider,
      callback: (currentThreads) => {
        setThreads(currentThreads)
      },
    })

    return () => unsubscribe()
  }, [provider])

  return threads
}

创建评论线程

允许用户在选中文本上创建评论线程,使用方式与标准 Comments 扩展相同:

const createThread = () => {
  const content = window.prompt('添加评论')
  if (!content || !editor) return

  editor
    .chain()
    .focus()
    .setThread({
      content,
      commentData: {
        userName: user.name,
      },
    })
    .run()
}
<button
  onClick={createThread}
  disabled={!editor?.state.selection || editor.state.selection.empty}
>
  添加评论
</button>

建议与线程的双向同步

当两个扩展同时启用时,修订跟踪扩展会自动同步建议和线程状态。这表示:

  • 解决 与建议关联的评论线程 → 接受 该建议
  • 删除 与建议关联的评论线程 → 拒绝 该建议
  • 接受 建议 → 解决 与之关联的评论线程
  • 拒绝 建议 → 删除 与之关联的评论线程

这一切都是自动的,无需额外设置。用户可以在建议列表或评论面板中审阅更改,二者始终保持一致。

构建线程侧边栏

创建一个侧边栏以显示评论线程。与建议相关的线程包含可用于渲染建议预览的元数据。

function ThreadsSidebar({ threads, provider, editor }) {
  const [showResolved, setShowResolved] = useState(false)

  const filteredThreads = threads.filter((t) =>
    showResolved ? !!t.resolvedAt : !t.resolvedAt,
  )

  return (
    <div style={{ width: 320, padding: '1rem' }}>
      <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
        <button onClick={() => setShowResolved(false)}>
          未解决
        </button>
        <button onClick={() => setShowResolved(true)}>
          已解决
        </button>
      </div>

      {filteredThreads.length === 0 ? (
        <p>无线程</p>
      ) : (
        filteredThreads.map((thread) => (
          <ThreadItem
            key={thread.id}
            thread={thread}
            provider={provider}
            editor={editor}
          />
        ))
      )}
    </div>
  )
}

渲染关联建议的线程

与建议关联的线程在 thread.data 中存储元数据。可利用这些内容在线程卡片中直接显示建议预览并提供接受/拒绝操作。

function ThreadItem({ thread, provider, editor }) {
  const comments = provider.getThreadComments(thread.id, true)
  const firstComment = comments?.[0]

  // 检查该线程是否关联建议
  const isSuggestionThread = thread.data?.source === 'suggestion'
  const suggestionType = thread.data?.suggestionType
  const suggestionText = thread.data?.suggestionText
  const suggestionId = thread.data?.suggestionId

  return (
    <div style={{
      padding: '0.75rem',
      marginBottom: '0.5rem',
      border: '1px solid #e2e8f0',
      borderRadius: '0.375rem',
    }}>
      {/* 关联建议的预览 */}
      {isSuggestionThread && (
        <div style={{
          padding: '0.5rem',
          marginBottom: '0.5rem',
          borderRadius: '0.25rem',
          backgroundColor: suggestionType === 'add' ? '#f0fdf4' : '#fef2f2',
        }}>
          <div style={{ fontSize: '0.75rem', fontWeight: 600 }}>
            {suggestionType === 'add' ? '+ 添加' : '− 删除'}
          </div>
          <div style={{ fontSize: '0.875rem' }}>
{suggestionText}
          </div>
          {!thread.resolvedAt && (
            <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
              <button onClick={() => editor.commands.acceptSuggestion({ id: suggestionId })}>
                接受
              </button>
              <button onClick={() => editor.commands.rejectSuggestion({ id: suggestionId })}>
                拒绝
              </button>
            </div>
          )}
        </div>
      )}

      {/* 评论内容 */}
      {firstComment && (
        <div>
          <div style={{ fontSize: '0.75rem', color: '#64748b' }}>
            {firstComment.data?.userName}
            {' · '}
            {new Date(firstComment.createdAt).toLocaleTimeString()}
          </div>
          <p style={{ fontSize: '0.875rem' }}>{firstComment.content}</p>
        </div>
      )}

      {/* 线程操作 */}
      <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
        {!thread.resolvedAt ? (
          <button onClick={() => editor.commands.resolveThread({ id: thread.id })}>
            {isSuggestionThread ? '接受并解决' : '解决'}
          </button>
        ) : (
          <button onClick={() => editor.commands.unresolveThread({ id: thread.id })}>
            取消解决
          </button>
        )}
        <button onClick={() => editor.commands.removeThread({ id: thread.id, deleteThread: true })}>
          {isSuggestionThread ? '拒绝并删除' : '删除'}
        </button>
      </div>
    </div>
  )
}

综合示例

将编辑器、工具栏和线程侧边栏组合成完整的审阅界面。

function ReviewApp() {
  const user = { id: 'user-123', name: 'John Doe' }
  const { ydoc, provider } = useMemo(() => {
    const doc = new Y.Doc()
    const collabProvider = new TiptapCollabProvider({
      appId: 'your-app-id',
      name: 'your-document-name',
      document: doc,
    })
    return { ydoc: doc, provider: collabProvider }
  }, [])

  const [isEnabled, setIsEnabled] = useState(true)
  const threads = useThreads(provider)

  const editor = useEditor({
    extensions: [
      StarterKit.configure({ undoRedo: false }),
      Collaboration.configure({ document: ydoc }),
      CommentsKit.configure({ provider }),
      TrackedChanges.configure({
        enabled: true,
        userId: user.id,
        userMetadata: { name: user.name },
      }),
    ],
  })

  if (!editor) return null

  return (
    <div style={{ display: 'flex' }}>
      <div style={{ flex: 1 }}>
        <div className="toolbar">
          <button onClick={() => {
            editor.commands.toggleTrackedChanges()
            setIsEnabled(!isEnabled)
          }}>
            {isEnabled ? '禁用' : '启用'} 修订跟踪
          </button>
          <button
            onClick={() => {
              const content = window.prompt('添加评论')
              if (content) {
                editor.chain().focus().setThread({
                  content,
                  commentData: { userName: user.name },
                }).run()
              }
            }}
            disabled={editor.state.selection.empty}
          >
            添加评论
          </button>
        </div>
        <EditorContent editor={editor} />
      </div>

      <ThreadsSidebar
        threads={threads}
        provider={provider}
        editor={editor}
      />
    </div>
  )
}

后续步骤