构建建议列表

Paid add-on

学习如何构建一个侧边栏组件,列出文档中所有未决建议,显示每个更改的作者,并允许用户接受或拒绝这些建议。

框架说明

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

前置条件

本指南假设你已经有一个包含已跟踪更改扩展的工作中的编辑器。如果还没有,请先参考编辑器设置指南

查询建议

该扩展导出一个 findSuggestions 工具,用于返回文档中的所有建议。在 onCreateonUpdate 中调用它以保持列表同步。

import { useState } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { TrackedChanges, findSuggestions } from '@tiptap-pro/extension-tracked-changes'

function Editor() {
  const [suggestions, setSuggestions] = useState([])

  const editor = useEditor({
    extensions: [
      StarterKit,
      TrackedChanges.configure({
        enabled: true,
        userId: 'user-123',
        userMetadata: { name: 'John Doe' },
      }),
    ],
    content: '<p>开始编辑以查看跟踪的更改。</p>',
    onCreate: ({ editor: currentEditor }) => {
      setSuggestions(findSuggestions(currentEditor))
    },
    onUpdate: ({ editor: currentEditor }) => {
      setSuggestions(findSuggestions(currentEditor))
    },
  })

  // 去重 — 一个建议可能跨越多个文本节点
  const uniqueSuggestions = Array.from(
    new Map(suggestions.map(s => [s.id, s])).values(),
  )

  if (!editor) {
    return null
  }

  return (
    <div className="editor-layout">
      <div className="editor-content">
        <EditorContent editor={editor} />
      </div>
      <SuggestionList
        suggestions={uniqueSuggestions}
        onAccept={(id) => {
          editor.commands.acceptSuggestion({ id })
          setSuggestions(findSuggestions(editor))
        }}
        onReject={(id) => {
          editor.commands.rejectSuggestion({ id })
          setSuggestions(findSuggestions(editor))
        }}
      />
    </div>
  )
}

★ 小贴士 ───────────────────────────────────── 当一个建议跨越格式边界(例如部分文本是加粗)时,它可能占据多个文本节点。findSuggestions 对每个文本节点返回一个条目,因此需要通过 id 去重,以避免在列表中多次显示同一条建议。 ─────────────────────────────────────────────────

构建 SuggestionList 组件

每条建议有一个 typeadddeletereplace)、更改的 text、用户元数据和时间戳。使用这些信息渲染一个清晰的列表。

function SuggestionList({ suggestions, onAccept, onReject }) {
  if (suggestions.length === 0) {
    return (
      <div className="suggestion-list">
        <p>没有待处理的建议</p>
      </div>
    )
  }

  return (
    <div className="suggestion-list">
      <h3>建议({suggestions.length})</h3>
      {suggestions.map(suggestion => (
        <SuggestionItem
          key={suggestion.id}
          suggestion={suggestion}
          onAccept={() => onAccept(suggestion.id)}
          onReject={() => onReject(suggestion.id)}
        />
      ))}
    </div>
  )
}

构建 SuggestionItem 组件

显示建议类型、文本预览、用户信息和操作按钮。

当某条建议影响块级节点(例如段落或标题)时,该建议会暴露 insertedNodesdeletedNodes——这些是描述完整节点结构的 JSONContent 数组。使用这些信息在内容文本上方渲染一个节点类型徽标。

function SuggestionItem({ suggestion, onAccept, onReject }) {
  const typeLabel = {
    add: '+ 插入',
    delete: '− 删除',
    replace: '~ 替换',
  }

  // 从块级节点建议中推导节点类型标签
  const nodes = suggestion.insertedNodes ?? suggestion.deletedNodes
  const nodeType = nodes?.[0]?.type

  return (
    <div className="suggestion-item">
      <div className="suggestion-item__type">
        {typeLabel[suggestion.type]}
      </div>

      {nodeType && (
        <span className="suggestion-item__node-badge">
          {nodeType}
        </span>
      )}

      <div className="suggestion-item__text">
        "{suggestion.text}"
        {suggestion.type === 'replace' && suggestion.replacedText && (
          <span className="suggestion-item__replaced-text">
            {' '}(原文:"{suggestion.replacedText}")
          </span>
        )}
      </div>

      <div className="suggestion-item__meta">
        {suggestion.userMetadata?.name || suggestion.userId}
        {' · '}
        {new Date(suggestion.createdAt).toLocaleTimeString()}
      </div>

      <div className="suggestion-item__actions">
        <button onClick={onAccept}>接受</button>
        <button onClick={onReject}>拒绝</button>
      </div>
    </div>
  )
}

根据你的 UI 对组件进行样式设置。下面是一个最小化的 CSS 起点:

.editor-layout {
  display: flex;
  gap: 1rem;
}

.editor-content {
  flex: 1;
}

.suggestion-list {
  width: 280px;
  padding: 1rem;
}

.suggestion-item {
  padding: 0.75rem;
  margin-bottom: 0.5rem;
  border-radius: 0.375rem;
  border: 1px solid #e2e8f0;
}

.suggestion-item__type {
  font-size: 0.75rem;
  font-weight: 600;
  margin-bottom: 0.25rem;
}

.suggestion-item__node-badge {
  display: inline-block;
  font-size: 0.65rem;
  font-weight: 600;
  padding: 0.1rem 0.4rem;
  border-radius: 0.25rem;
  background: #f1f5f9;
  color: #475569;
  margin-bottom: 0.25rem;
}

.suggestion-item__text {
  font-size: 0.875rem;
  margin-bottom: 0.25rem;
}

.suggestion-item__replaced-text {
  color: #64748b;
}

.suggestion-item__meta {
  font-size: 0.75rem;
  color: #64748b;
  margin-bottom: 0.5rem;
}

.suggestion-item__actions {
  display: flex;
  gap: 0.5rem;
}

添加批量操作

在列表上方添加按钮,一次接受或拒绝所有建议。

function SuggestionList({ suggestions, onAccept, onReject, onAcceptAll, onRejectAll }) {
  return (
    <div className="suggestion-list">
      <h3>建议({suggestions.length})</h3>

      {suggestions.length > 0 && (
        <div className="suggestion-list__bulk-actions">
          <button onClick={onAcceptAll}>全部接受</button>
          <button onClick={onRejectAll}>全部拒绝</button>
        </div>
      )}

      {suggestions.map(suggestion => (
        <SuggestionItem
          key={suggestion.id}
          suggestion={suggestion}
          onAccept={() => onAccept(suggestion.id)}
          onReject={() => onReject(suggestion.id)}
        />
      ))}
    </div>
  )
}

在父组件中关联批量操作:

<SuggestionList
  suggestions={uniqueSuggestions}
  onAccept={(id) => {
    editor.commands.acceptSuggestion({ id })
    setSuggestions(findSuggestions(editor))
  }}
  onReject={(id) => {
    editor.commands.rejectSuggestion({ id })
    setSuggestions(findSuggestions(editor))
  }}
  onAcceptAll={() => {
    editor.commands.acceptAllSuggestions()
    setSuggestions([])
  }}
  onRejectAll={() => {
    editor.commands.rejectAllSuggestions()
    setSuggestions([])
  }}
/>

筛选建议

使用 findSuggestions 的选项参数,按类型或用户筛选:

import { findSuggestions } from '@tiptap-pro/extension-tracked-changes'

// 仅插入内容
const insertions = findSuggestions(editor, 'suggestion', { type: 'add' })

// 仅删除内容
const deletions = findSuggestions(editor, 'suggestion', { type: 'delete' })

// 仅来自特定用户
const userSuggestions = findSuggestions(editor, 'suggestion', { userId: 'user-123' })

你可以在侧边栏添加筛选控件,让用户查看特定类型的建议。

监听事件

为了更响应式的更新,可监听扩展的事件,而不是(或除了)在 onUpdate 中轮询:

import { useEffect } from 'react'

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

  const updateSuggestions = () => {
    setSuggestions(findSuggestions(editor))
  }

  editor.on('trackedChanges:suggestionCreated', updateSuggestions)
  editor.on('trackedChanges:suggestionAccepted', updateSuggestions)
  editor.on('trackedChanges:suggestionRejected', updateSuggestions)
  editor.on('trackedChanges:suggestionRemoved', updateSuggestions)
  editor.on('trackedChanges:suggestionsUpdated', updateSuggestions)

  return () => {
    editor.off('trackedChanges:suggestionCreated', updateSuggestions)
    editor.off('trackedChanges:suggestionAccepted', updateSuggestions)
    editor.off('trackedChanges:suggestionRejected', updateSuggestions)
    editor.off('trackedChanges:suggestionRemoved', updateSuggestions)
    editor.off('trackedChanges:suggestionsUpdated', updateSuggestions)
  }
}, [editor])

下一步

  • 添加评论集成,实现完整的审核工作流
  • 探索工具 API,获取更多查询函数,如 getSuggestionAtSelectionfindSuggestionsInRange