Tracked Changes extension
The Tracked Changes extension enables suggestion mode for collaborative editing workflows. When enabled, all edits appear as proposals that can be accepted or rejected, similar to change tracking in word processors.
Experimental
This extension is under active development. The API may change in future releases. All versions below 1.0.0 are expected to be unstable, use them at your own risk.
Install
npm install @tiptap-pro/extension-tracked-changesBasic Setup
import { Editor } from '@tiptap/core'
import { TrackedChanges } from '@tiptap-pro/extension-tracked-changes'
const editor = new Editor({
extensions: [
TrackedChanges.configure({
enabled: true,
userId: 'user-123',
userMetadata: { name: 'John Doe' },
}),
],
})Settings
enabled
Enable or disable track changes mode. When enabled, all edits become suggestions instead of direct changes.
Default: false
TrackedChanges.configure({
enabled: true,
})userId
The ID of the current user making suggestions. This should come from your authentication system.
Default: 'anonymous'
TrackedChanges.configure({
userId: 'user-123',
})userMetadata
Arbitrary metadata about the current user, stored as a JSON-serializable object on each suggestion. Useful for storing display names, avatars, or other custom data alongside the suggestion without requiring a separate user store.
Default: null
TrackedChanges.configure({
userId: 'user-123',
userMetadata: {
name: 'John Doe',
avatar: 'https://example.com/avatar.jpg',
role: 'editor',
},
})suggestionGroupingTimeout
Time window in milliseconds for grouping adjacent suggestions. If a user makes continuous edits in the same area within this time window, they will be merged into a single suggestion. Set to 0 to disable grouping.
Default: 2000
TrackedChanges.configure({
suggestionGroupingTimeout: 3000,
})onSuggestionCreate
Callback fired when a new suggestion is created. Receives the suggestion object containing id, type, userId, createdAt, from, to, and text.
Default: undefined
TrackedChanges.configure({
onSuggestionCreate: (suggestion) => {
console.log('New suggestion created:', suggestion)
// Update your UI, notify other users, etc.
},
})onSuggestionAccept
Callback fired when a suggestion is accepted. Receives the suggestion ID.
Default: undefined
TrackedChanges.configure({
onSuggestionAccept: (id) => {
console.log('Suggestion accepted:', id)
},
})onSuggestionReject
Callback fired when a suggestion is rejected. Receives the suggestion ID.
Default: undefined
TrackedChanges.configure({
onSuggestionReject: (id) => {
console.log('Suggestion rejected:', id)
},
})Behavior
The extension tracks three types of changes:
- Insertions (
add) — New content added to the document - Deletions (
delete) — Existing content marked for removal - Replacements (
replace) — Content that was simultaneously deleted and replaced with new content
Each suggestion carries metadata including who created it, when it was created, and a unique identifier.
Suggestion Grouping
When a user types continuously or deletes multiple characters in sequence, these edits are grouped into a single suggestion rather than creating one suggestion per character. Grouping occurs when:
- The new edit is adjacent to an existing suggestion (immediately before or after)
- The existing suggestion was created by the same user
- The existing suggestion is the same type (both insertions or both deletions)
- The time since the last edit is within the grouping timeout window (default: 2000ms)
When edits are grouped, the suggestion's timestamp is updated to keep the grouping window active. This means continuous typing will keep extending the same suggestion until the user pauses for longer than the timeout.
// Disable grouping entirely
TrackedChanges.configure({
suggestionGroupingTimeout: 0,
})
// Longer grouping window (5 seconds)
TrackedChanges.configure({
suggestionGroupingTimeout: 5000,
})Deletion Merging
When a user deletes content that overlaps with or is adjacent to existing delete suggestions, the marks are automatically merged to prevent nested or overlapping deletions. The merged suggestion uses the metadata (ID, user, timestamp) from the oldest existing delete mark in the range.
This ensures that:
- The document never contains nested delete marks
- Related deletions are visually grouped together
- The original author of the first deletion is preserved
Mixed Content Handling
When deleting or replacing content that contains a mix of original text and previously suggested insertions:
- Suggested insertions (text with
addmarks) are immediately removed from the document - Original content (text without
addmarks) is marked with adeletesuggestion
This behavior matches user expectations: if you delete text that was only suggested (not yet accepted), it simply disappears. Original document content requires explicit acceptance to be removed.
Atom Node Tracking
The extension automatically tracks changes to atom nodes like images, horizontal rules, and other non-text content. These nodes cannot have marks, so the extension uses node attributes (suggestionId, suggestionType, suggestionUserId, suggestionCreatedAt, suggestionUserMetadata) instead. This happens transparently — atom nodes appear in the same suggestion queries and can be accepted or rejected like any other suggestion.
Collaboration Compatibility
The extension is designed to work with Yjs-based collaboration. It automatically ignores:
- Remote sync transactions from Yjs (
y-sync$meta) - History transactions (undo/redo are handled natively by ProseMirror)
- Transactions with
addToHistory: false(typically remote changes)
This means only local user edits are tracked as suggestions.
Comments Integration
The Tracked Changes extension integrates with the Comments extension to enable review workflows where suggestions and comment threads are handled together. The integration is built on top of the event systems exposed by both extensions.
When both extensions are active, comment threads are automatically linked to suggestions through thread metadata. This enables bidirectional synchronization:
- Resolving a comment thread linked to a suggestion automatically accepts the suggestion
- Deleting a comment thread linked to a suggestion automatically rejects the suggestion
- Accepting a suggestion automatically resolves its linked comment thread
- Rejecting a suggestion automatically deletes its linked comment thread
When a suggestion's content changes (e.g., the user continues typing within a tracked insertion), the linked thread's metadata is updated to stay in sync.
This means users can review changes through either the suggestion UI or the comments panel — both stay consistent.
Usage
Enabling and Disabling Tracked Changes
Toggle track changes mode on and off:
// Enable track changes
editor.commands.enableTrackedChanges()
// Disable track changes
editor.commands.disableTrackedChanges()
// Toggle track changes
editor.commands.toggleTrackedChanges()Setting the Current User
Change the user for new suggestions:
editor.commands.setTrackedChangesUser({
userId: 'user-456',
userMetadata: { name: 'Jane Smith' },
})Accepting and Rejecting Suggestions
Accept or reject suggestions individually:
// Accept suggestion at current selection
editor.commands.acceptSuggestion()
// Reject suggestion at current selection
editor.commands.rejectSuggestion()
// Accept specific suggestion by ID
editor.commands.acceptSuggestion({ id: 'suggestion-123' })
// Reject specific suggestion by ID
editor.commands.rejectSuggestion({ id: 'suggestion-123' })Batch Operations
Accept or reject all suggestions at once:
// Accept all suggestions
editor.commands.acceptAllSuggestions()
// Reject all suggestions
editor.commands.rejectAllSuggestions()Accept or reject suggestions within a specific document range:
// Accept suggestions in a range
editor.commands.acceptSuggestionsInRange({ from: 10, to: 50 })
// Reject suggestions in a range
editor.commands.rejectSuggestionsInRange({ from: 10, to: 50 })Accept or reject all suggestions by a specific user:
// Accept all suggestions from a user
editor.commands.acceptSuggestionsByUser({ userId: 'user-123' })
// Reject all suggestions from a user
editor.commands.rejectSuggestionsByUser({ userId: 'user-123' })Commands
enableTrackedChanges()
Enable track changes mode. All subsequent edits will become suggestions.
editor.commands.enableTrackedChanges()disableTrackedChanges()
Disable track changes mode. Edits will be applied directly to the document.
editor.commands.disableTrackedChanges()toggleTrackedChanges()
Toggle track changes mode on or off.
editor.commands.toggleTrackedChanges()setTrackedChangesUser()
Change the current user for new suggestions.
| Argument | Type | Description |
|---|---|---|
userId | string | The new user ID |
userMetadata | Record<string, unknown> | null | Optional arbitrary metadata about the user |
editor.commands.setTrackedChangesUser({
userId: 'user-789',
userMetadata: { name: 'Alice Johnson' },
})acceptSuggestion()
Accept a suggestion, applying the proposed change. For insertions, the suggestion mark is removed and text is kept. For deletions, the marked text is removed. For replacements, the old text is removed and the new text is kept.
| Argument | Type | Description |
|---|---|---|
id | string | Optional suggestion ID. If omitted, uses the suggestion at the current selection. |
// Accept suggestion at selection
editor.commands.acceptSuggestion()
// Accept specific suggestion
editor.commands.acceptSuggestion({ id: 'suggestion-123' })rejectSuggestion()
Reject a suggestion, reverting the proposed change. For insertions, the marked text is removed. For deletions, the suggestion mark is removed and text is kept. For replacements, the new text is removed and the old text is kept.
| Argument | Type | Description |
|---|---|---|
id | string | Optional suggestion ID. If omitted, uses the suggestion at the current selection. |
// Reject suggestion at selection
editor.commands.rejectSuggestion()
// Reject specific suggestion
editor.commands.rejectSuggestion({ id: 'suggestion-123' })acceptAllSuggestions()
Accept all suggestions in the document.
editor.commands.acceptAllSuggestions()rejectAllSuggestions()
Reject all suggestions in the document.
editor.commands.rejectAllSuggestions()acceptSuggestionsInRange()
Accept all suggestions within a specific document position range.
| Argument | Type | Description |
|---|---|---|
from | number | Start position of the range |
to | number | End position of the range |
editor.commands.acceptSuggestionsInRange({ from: 0, to: 100 })rejectSuggestionsInRange()
Reject all suggestions within a specific document position range.
| Argument | Type | Description |
|---|---|---|
from | number | Start position of the range |
to | number | End position of the range |
editor.commands.rejectSuggestionsInRange({ from: 0, to: 100 })acceptSuggestionsByUser()
Accept all suggestions created by a specific user.
| Argument | Type | Description |
|---|---|---|
userId | string | The user ID whose suggestions to accept |
editor.commands.acceptSuggestionsByUser({ userId: 'user-123' })rejectSuggestionsByUser()
Reject all suggestions created by a specific user.
| Argument | Type | Description |
|---|---|---|
userId | string | The user ID whose suggestions to reject |
editor.commands.rejectSuggestionsByUser({ userId: 'user-123' })Events
The extension emits events you can listen to with editor.on(). All events include the transaction that triggered them.
trackedChanges:suggestionCreated
Fired when a new suggestion is created (either from user editing or restored via undo/collaboration).
editor.on('trackedChanges:suggestionCreated', ({ suggestion, transaction }) => {
console.log('New suggestion:', suggestion.id, suggestion.type)
})trackedChanges:suggestionAccepted
Fired when a suggestion is accepted.
editor.on('trackedChanges:suggestionAccepted', ({ suggestionId, suggestion, transaction }) => {
console.log('Accepted:', suggestionId)
})trackedChanges:suggestionRejected
Fired when a suggestion is rejected.
editor.on('trackedChanges:suggestionRejected', ({ suggestionId, suggestion, transaction }) => {
console.log('Rejected:', suggestionId)
})trackedChanges:suggestionRemoved
Fired when a suggestion is removed from the document (e.g., by deleting an "add" suggestion's content).
editor.on('trackedChanges:suggestionRemoved', ({ suggestionId, suggestion, removedBy, canRestore }) => {
// removedBy is 'edit' or 'delete'
console.log('Removed:', suggestionId, 'by', removedBy)
})trackedChanges:suggestionsUpdated
Fired after bulk operations like acceptAllSuggestions, rejectSuggestionsInRange, etc.
editor.on('trackedChanges:suggestionsUpdated', ({ suggestions, operation, affectedIds }) => {
// operation: 'acceptAll' | 'rejectAll' | 'acceptInRange' | 'rejectInRange' | 'acceptByUser' | 'rejectByUser'
console.log(`${operation} affected ${affectedIds.length} suggestions`)
})trackedChanges:suggestionRangeChanged
Fired when a suggestion's document position changes due to other edits in the document.
editor.on('trackedChanges:suggestionRangeChanged', ({ suggestionId, oldRange, newRange, suggestion }) => {
console.log(`Suggestion ${suggestionId} moved from ${oldRange.from}-${oldRange.to} to ${newRange.from}-${newRange.to}`)
})trackedChanges:enabled
Fired when track changes mode is enabled.
editor.on('trackedChanges:enabled', ({ userId }) => {
console.log('Track changes enabled for', userId)
})trackedChanges:disabled
Fired when track changes mode is disabled.
editor.on('trackedChanges:disabled', ({ userId }) => {
console.log('Track changes disabled')
})Utilities
The extension exports utility functions for querying and working with suggestions.
findSuggestions()
Find all suggestions in the document, with optional filtering.
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
markName | string | 'suggestion' | The name of the suggestion mark |
options | FindSuggestionsOptions | {} | Optional filters (see below) |
Options
| Option | Type | Description |
|---|---|---|
type | 'add' | 'delete' | 'replace' | Filter by suggestion type |
userId | string | Filter by user ID |
id | string | Filter by suggestion ID |
Returns
Suggestion[] — Array of suggestions found in the document.
import { findSuggestions } from '@tiptap-pro/extension-tracked-changes'
// Find all suggestions
const allSuggestions = findSuggestions(editor)
// Find suggestions by type
const insertions = findSuggestions(editor, 'suggestion', { type: 'add' })
const deletions = findSuggestions(editor, 'suggestion', { type: 'delete' })
const replacements = findSuggestions(editor, 'suggestion', { type: 'replace' })
// Find suggestions by user
const userSuggestions = findSuggestions(editor, 'suggestion', { userId: 'user-123' })findSuggestionById()
Find a specific suggestion by ID.
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
id | string | — | The suggestion ID to find |
markName | string | 'suggestion' | The name of the suggestion mark |
Returns
Suggestion | undefined — The suggestion if found, undefined otherwise.
import { findSuggestionById } from '@tiptap-pro/extension-tracked-changes'
const suggestion = findSuggestionById(editor, 'suggestion-123')
if (suggestion) {
console.log(`Found suggestion: ${suggestion.text}`)
}findSuggestionRanges()
Find all document ranges covered by a suggestion. A single suggestion may span multiple text nodes when it crosses formatting boundaries.
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
id | string | — | The suggestion ID to find |
markName | string | 'suggestion' | The name of the suggestion mark |
Returns
Array<{ from: number; to: number }> — Array of position ranges covered by this suggestion. Adjacent ranges are automatically merged.
import { findSuggestionRanges } from '@tiptap-pro/extension-tracked-changes'
const ranges = findSuggestionRanges(editor, 'suggestion-123')
// Returns: [{ from: 10, to: 25 }, { from: 30, to: 35 }]getSuggestionAtSelection()
Get the suggestion at the current selection position. Works with both mark-based suggestions (text) and attribute-based suggestions (atom nodes).
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
markName | string | 'suggestion' | The name of the suggestion mark |
Returns
Suggestion | undefined — The suggestion at the current cursor position, or undefined if none.
import { getSuggestionAtSelection } from '@tiptap-pro/extension-tracked-changes'
const suggestion = getSuggestionAtSelection(editor)
if (suggestion) {
// Show accept/reject buttons in your UI
showSuggestionPopup(suggestion)
}getSuggestionAtPosition()
Get the suggestion at a specific document position.
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
pos | number | — | The document position to check |
markName | string | 'suggestion' | The name of the suggestion mark |
Returns
Suggestion | null — The suggestion at the position, or null if none found.
import { getSuggestionAtPosition } from '@tiptap-pro/extension-tracked-changes'
const suggestion = getSuggestionAtPosition(editor, 42)findSuggestionsInRange()
Find all suggestions that overlap with a specific document range.
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
from | number | — | Start position of the range |
to | number | — | End position of the range |
filters | { userId?: string; type?: SuggestionType } | undefined | Optional filters |
markName | string | 'suggestion' | The name of the suggestion mark |
Returns
Suggestion[] — Array of suggestions overlapping the range.
import { findSuggestionsInRange } from '@tiptap-pro/extension-tracked-changes'
// Find all suggestions between positions 10 and 50
const suggestions = findSuggestionsInRange(editor, 10, 50)
// With filters
const userInsertions = findSuggestionsInRange(editor, 10, 50, {
userId: 'user-123',
type: 'add',
})suggestionExists()
Check whether a suggestion still exists in the document.
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
suggestionId | string | — | The suggestion ID to check |
markName | string | 'suggestion' | The name of the suggestion mark |
Returns
boolean — true if the suggestion exists.
import { suggestionExists } from '@tiptap-pro/extension-tracked-changes'
if (suggestionExists(editor, 'suggestion-123')) {
// Suggestion is still in the document
}getSuggestionRange()
Get the bounding position range of a suggestion.
| Argument | Type | Default | Description |
|---|---|---|---|
editor | Editor | — | The Tiptap editor instance |
suggestionId | string | — | The suggestion ID |
markName | string | 'suggestion' | The name of the suggestion mark |
Returns
{ from: number; to: number } | null — The bounding range, or null if not found.
import { getSuggestionRange } from '@tiptap-pro/extension-tracked-changes'
const range = getSuggestionRange(editor, 'suggestion-123')
if (range) {
console.log(`Suggestion spans from ${range.from} to ${range.to}`)
}acceptSuggestions()
Accept multiple suggestions by their IDs. Processes each suggestion individually.
| Argument | Type | Description |
|---|---|---|
editor | Editor | The Tiptap editor instance |
suggestionIds | string[] | Array of suggestion IDs to accept |
Returns
boolean — true if at least one suggestion was accepted.
import { acceptSuggestions } from '@tiptap-pro/extension-tracked-changes'
acceptSuggestions(editor, ['suggestion-1', 'suggestion-2', 'suggestion-3'])rejectSuggestions()
Reject multiple suggestions by their IDs. Processes each suggestion individually.
| Argument | Type | Description |
|---|---|---|
editor | Editor | The Tiptap editor instance |
suggestionIds | string[] | Array of suggestion IDs to reject |
Returns
boolean — true if at least one suggestion was rejected.
import { rejectSuggestions } from '@tiptap-pro/extension-tracked-changes'
rejectSuggestions(editor, ['suggestion-1', 'suggestion-2'])generateSuggestionId()
Generate a unique suggestion ID. Combines timestamp with random string for uniqueness.
Returns
string — A unique suggestion ID in the format suggestion-{timestamp}-{random}.
import { generateSuggestionId } from '@tiptap-pro/extension-tracked-changes'
const id = generateSuggestionId()
// Returns: 'suggestion-1706621696789-k7x2m9p'getTimestamp()
Get the current time as an ISO 8601 timestamp string.
Returns
string — Current time as ISO 8601 string.
import { getTimestamp } from '@tiptap-pro/extension-tracked-changes'
const timestamp = getTimestamp()
// Returns: '2024-01-30T12:34:56.789Z'Code Block Compatibility
By default, Tiptap code blocks do not allow marks, which prevents suggestion marks from being applied inside code content. The extension exports a helper to work around this:
import { CodeBlock } from '@tiptap/extension-code-block'
import { TrackedChanges, withSuggestionMarkOnCodeBlock } from '@tiptap-pro/extension-tracked-changes'
import StarterKit from '@tiptap/starter-kit'
const TrackableCodeBlock = withSuggestionMarkOnCodeBlock(CodeBlock)
const editor = new Editor({
extensions: [
StarterKit.configure({
codeBlock: false, // Disable the default code block
}),
TrackableCodeBlock, // Use the patched version
TrackedChanges.configure({ enabled: true, userId: 'user-123' }),
],
})This also works with CodeBlockLowlight or any other code block extension.
Types
The extension exports TypeScript types for working with suggestions.
SuggestionType
Public-facing suggestion type returned by the query API:
type SuggestionType = 'add' | 'delete' | 'replace'SuggestionMarkType
Internal mark-level type stored on suggestion marks. Replace suggestions use separate mark types for their deletion and insertion parts:
type SuggestionMarkType = 'add' | 'delete' | 'replaceDeletion' | 'replaceInsertion'Suggestion
Represents a suggestion found in the document:
type Suggestion = {
id: string
type: SuggestionType
userId: string
createdAt: string
userMetadata: Record<string, unknown> | null
from: number
to: number
text: string
// Only present for 'replace' suggestions:
deletionRange?: { from: number; to: number }
insertionRange?: { from: number; to: number }
replacedText?: string
}For replace suggestions:
textcontains the new (inserted) textreplacedTextcontains the old (deleted) textdeletionRangeandinsertionRangegive the exact positions of each partfromandtocover the full range of both parts
SuggestionMarkAttrs
Attributes stored on the suggestion mark:
type SuggestionMarkAttrs = {
id: string
type: SuggestionMarkType
userId: string
createdAt: string
userMetadata: Record<string, unknown> | null
}TrackedChangesOptions
Configuration options for the extension:
type TrackedChangesOptions = {
enabled: boolean
userId: string
userMetadata?: Record<string, unknown> | null
suggestionGroupingTimeout: number
onSuggestionCreate?: (suggestion: Suggestion) => void
onSuggestionAccept?: (id: string) => void
onSuggestionReject?: (id: string) => void
}Type Helpers
The extension exports constants and helper functions for working with suggestion types:
import {
SUGGESTION_TYPES,
isAddLikeType,
isDeleteLikeType,
isReplaceMarkType,
markTypeToSuggestionType,
} from '@tiptap-pro/extension-tracked-changes'
// Constants
SUGGESTION_TYPES.ADD // 'add'
SUGGESTION_TYPES.DELETE // 'delete'
SUGGESTION_TYPES.REPLACE_DELETION // 'replaceDeletion'
SUGGESTION_TYPES.REPLACE_INSERTION // 'replaceInsertion'
// Check if a mark type represents added content (matches 'add' and 'replaceInsertion')
isAddLikeType('add') // true
isAddLikeType('replaceInsertion') // true
// Check if a mark type represents deleted content (matches 'delete' and 'replaceDeletion')
isDeleteLikeType('delete') // true
isDeleteLikeType('replaceDeletion') // true
// Check if a mark type is one of the replace sub-types
isReplaceMarkType('replaceDeletion') // true
// Convert a mark-level type to the public-facing suggestion type
markTypeToSuggestionType('replaceDeletion') // 'replace'
markTypeToSuggestionType('add') // 'add'Styling
The suggestion mark renders as a <span> element with data attributes that can be targeted with CSS:
/* All suggestions */
[data-suggestion] {
/* Base styles for all suggestions */
}
/* Insertions */
[data-suggestion-type="add"] {
background-color: rgba(0, 255, 0, 0.2);
text-decoration: underline;
}
/* Deletions */
[data-suggestion-type="delete"] {
background-color: rgba(255, 0, 0, 0.2);
text-decoration: line-through;
}
/* Replacement deletions (old text being replaced) */
[data-suggestion-type="replaceDeletion"] {
background-color: rgba(255, 0, 0, 0.2);
text-decoration: line-through;
}
/* Replacement insertions (new text replacing old) */
[data-suggestion-type="replaceInsertion"] {
background-color: rgba(0, 255, 0, 0.2);
text-decoration: underline;
}
/* Style suggestions by user */
[data-suggestion-user="user-123"] {
border-bottom: 2px solid blue;
}Available Data Attributes
| Attribute | Description |
|---|---|
data-suggestion | Always present on suggestion mark elements |
data-suggestion-id | The unique suggestion ID |
data-suggestion-type | 'add', 'delete', 'replaceDeletion', or 'replaceInsertion' |
data-suggestion-user | The ID of the user who created the suggestion |
data-suggestion-created | ISO timestamp when the suggestion was created |
data-suggestion-user-metadata | JSON-serialized user metadata object (only present when userMetadata is set) |
Known limitations
This extension is in active development and will change significantly over the coming months. The following limitations are known and will be addressed in future releases:
- Attribute or mark changes (e.g. toggling bold, changing a link URL) are not tracked
- Node type changes (e.g. converting a paragraph to a heading) are not tracked
- Inserting new lines or removing text-based nodes (e.g. deleting an entire paragraph) is not handled
- There is no explicit suggestion mode that renders a separate "clean" document alongside the tracked version