Tracked Changes extension

Pro 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-changes

Basic 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:

  1. The new edit is adjacent to an existing suggestion (immediately before or after)
  2. The existing suggestion was created by the same user
  3. The existing suggestion is the same type (both insertions or both deletions)
  4. 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 add marks) are immediately removed from the document
  • Original content (text without add marks) is marked with a delete suggestion

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.

ArgumentTypeDescription
userIdstringThe new user ID
userMetadataRecord<string, unknown> | nullOptional 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.

ArgumentTypeDescription
idstringOptional 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.

ArgumentTypeDescription
idstringOptional 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.

ArgumentTypeDescription
fromnumberStart position of the range
tonumberEnd position of the range
editor.commands.acceptSuggestionsInRange({ from: 0, to: 100 })

rejectSuggestionsInRange()

Reject all suggestions within a specific document position range.

ArgumentTypeDescription
fromnumberStart position of the range
tonumberEnd position of the range
editor.commands.rejectSuggestionsInRange({ from: 0, to: 100 })

acceptSuggestionsByUser()

Accept all suggestions created by a specific user.

ArgumentTypeDescription
userIdstringThe user ID whose suggestions to accept
editor.commands.acceptSuggestionsByUser({ userId: 'user-123' })

rejectSuggestionsByUser()

Reject all suggestions created by a specific user.

ArgumentTypeDescription
userIdstringThe 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.

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
markNamestring'suggestion'The name of the suggestion mark
optionsFindSuggestionsOptions{}Optional filters (see below)

Options

OptionTypeDescription
type'add' | 'delete' | 'replace'Filter by suggestion type
userIdstringFilter by user ID
idstringFilter 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.

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
idstringThe suggestion ID to find
markNamestring'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.

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
idstringThe suggestion ID to find
markNamestring'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).

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
markNamestring'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.

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
posnumberThe document position to check
markNamestring'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.

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
fromnumberStart position of the range
tonumberEnd position of the range
filters{ userId?: string; type?: SuggestionType }undefinedOptional filters
markNamestring'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.

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
suggestionIdstringThe suggestion ID to check
markNamestring'suggestion'The name of the suggestion mark

Returns

booleantrue 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.

ArgumentTypeDefaultDescription
editorEditorThe Tiptap editor instance
suggestionIdstringThe suggestion ID
markNamestring'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.

ArgumentTypeDescription
editorEditorThe Tiptap editor instance
suggestionIdsstring[]Array of suggestion IDs to accept

Returns

booleantrue 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.

ArgumentTypeDescription
editorEditorThe Tiptap editor instance
suggestionIdsstring[]Array of suggestion IDs to reject

Returns

booleantrue 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:

  • text contains the new (inserted) text
  • replacedText contains the old (deleted) text
  • deletionRange and insertionRange give the exact positions of each part
  • from and to cover 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

AttributeDescription
data-suggestionAlways present on suggestion mark elements
data-suggestion-idThe unique suggestion ID
data-suggestion-type'add', 'delete', 'replaceDeletion', or 'replaceInsertion'
data-suggestion-userThe ID of the user who created the suggestion
data-suggestion-createdISO timestamp when the suggestion was created
data-suggestion-user-metadataJSON-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