带有评论的跟踪更改
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>
)
}