Visually compare snapshots of your documents

Tired of playing "spot the difference" with your document versions?

The Snapshot Compare extension adds extra functionality to the Document History by allowing you to visually compare changes made between two versions of a document so you can track what’s been added, removed, or modified. These comparisons are called diffs.

Whether you're collaborating with a team or editing solo, this helps you track changes over time and understand who made specific edits. By highlighting differences within the editor and attributing them to their authors, Snapshot Compare brings transparency and efficiency to the document lifecycle.

Install

Subscription required

This extension requires a valid Entry, Business or Enterprise subscription and a running Tiptap Cloud instance. To install the extension you need access to our private registry, set this up first.

Install the extension from our private registry:

npm install @tiptap-pro/extension-snapshot-compare

Settings

You can configure the SnapshotCompare extension with the following options:

SettingTypeDefaultDescription
providerTiptapCollabProvidernullThe Collaboration provider instance
mapDiffToDecorationsfunction() => {}Control mapping a diff into a decoration to display their content
const provider = new TiptapCollabProvider({
  // ...
})

const editor = new Editor({
  // ...
  extensions: [
    // ...
    SnapshotCompare.configure({
      provider,
    }),
  ],
})

Using mapDiffToDecorations for diff decorations

The extension has a default mapping (defaultMapDiffToDecorations) to represent diffs as ProseMirror decorations. For more complex integrations and control, you can customize this mapping with the mapDiffToDecorations option.

Example: Applying custom predefined background colors to inline inserts

SnapshotCompare.configure({
  mapDiffToDecorations: ({ diff, tr, editor, defaultMapDiffToDecorations }) => {
    if (diff.type === 'inline-insert') {
      // return prosemirror decoration(s) or null
      return Decoration.inline(
        diff.from,
        diff.to,
        {
          class: 'diff',
          style: {
            backgroundColor: diff.attribution.color.backgroundColor,
          },
        },
        // pass the diff as the decoration's spec, this is required for `extractAttributeChanges`
        { diff },
      )
    }

    // fallback to the default mapping
    return defaultMapDiffToDecorations({
      diff,
      tr,
      editor,
      attributes: {
        // add custom attributes to the decorations
        'data-tiptap-user-id': myUserIdMapping[diff.attribution.userId],
      },
    })
  },
})

Storage

The SnapshotCompare storage object contains the following properties:

KeyTypeDescription
isPreviewingbooleanIndicates whether the diff view is active
diffsDiff[]The diffs that are displayed in the diff view
previousContentJSONContent | nullThe content before the diff view was applied

Use the isPreviewing property to check if the diff view is currently active:

if (editor.storage.snapshotCompare.isPreviewing) {
  // The diff view is currently active
}

Use the diffs property to access the diffs displayed in the diff view:

editor.storage.snapshotCompare.diffs

The property previousContent is used internally by the extension to restore the content when exiting the diff view. Typically, you do not need to interact with it directly.

Commands

CommandDescription
compareVersionsDiffs two versions and renders the diff within the editor
showDiffGiven a change tracking transform, show the diff within the editor
hideDiffHide the diff and restore the previous content

compareVersions

Use the compareVersions command to compute and display the differences between two document versions.

editor.chain().compareVersions({
  fromVersion: 1,
})

Options

You can pass in additional options for more control over the diffing process:

KeyDescription
fromVersionThe version to compare from. The order between fromVersion and toVersion is flexible
toVersionThe version to compare to (default: latest version)
hydrateUserDataAdd contextual data to each user's changes
onCompareHandle the diffing result manually
enableDebuggingEnable verbose logging for troubleshooting

Using hydrateUserData to add metadata

Each diff has an attribution field, which allows you to add additional metadata with the hydrateUserData callback function.

Note

Do note that the userId is populated by the TiptapCollabProvider and should be used to identify the user who made the change. Without the user field provided by the provider, the userId will be null. See more information in the TiptapCollabProvider documentation.

Example: Color-coding diffs based on user

const colorMapping = new Map([
  ['user-1', '#ff0000'],
  ['user-2', '#00ff00'],
  ['user-3', '#0000ff'],
])

editor.chain().compareVersions({
  fromVersion: 1,
  toVersion: 3,
  hydrateUserData: ({ userId }) => {
    return {
      color: {
        backgroundColor: colorMapping.get(userId),
      },
    }
  },
})

editor.storage.snapshotCompare.diffs[0].attribution.color.backgroundColor // '#ff0000'

Using onCompare to customize the diffing process

If you need more control over the diffing process, use the onCompare option to receive the result and handle it yourself.

Example: Filtering diffs by user

editor.chain().compareVersions({
  fromVersion: 1,
  toVersion: 3,
  onCompare: (ctx) => {
    if (ctx.error) {
      // handle errors that occurred in the diffing process
      console.error(ctx.error)
      return
    }

    // filter the diffs to display only the changes made by a specific user
    const diffsToDisplay = ctx.diffSet.filter((diff) => diff.attribution.userId === 'user-1')

    editor.commands.showDiff(ctx.tr, { diffs: diffsToDisplay })
  },
})

showDiff

Use the showDiff command to display the diff within the editor using a change tracking transform (tr). This represents all of the changes made to the document since the last snapshot. You can use this transform to show the diff in the editor.

Typically, you use this command after customizing or filtering diffs with compareVersions, onCompare.

The showDiff command temporarily replaces the current editor content with the diff view, showing the differences between versions. It also stashes the content currently displayed in editor so that you can restore it later.

// This will display the changes that change tracking transform recorded in the editor
editor.commands.showDiff(tr)

Options

You can pass additional options to control how the diffs are displayed:

KeyDescription
diffsAn array of diffs to visualize

Example: Displaying specific diffs

// This will display only the diffs made by the user with the ID 'user-1'
const diffsToDisplay = tr.toDiff().filter((diff) => diff.attribution.userId === 'user-1')

editor.commands.showDiff(tr, { diffs: diffsToDisplay })

hideDiff

Use the hideDiff command to hide the diff and restore the previous content.

// This will hide the diff view and restore the previous content
editor.commands.hideDiff()

Working with NodeView (Advanced)

When using custom node views, the default diff mapping may not work as expected. You can customize the mapping and render the diffs directly within the custom node view.

Use the extractAttributeChanges helper to extract attribute changes in nodes. This allows you to access the previous and current attributes of a node, making it possible to highlight attribute changes within your custom node views.

Note

When mapping the diffs into decorations yourself, you need to pass the diff as the decoration's spec. This is required for extractAttributeChanges to work correctly.

Example: Customizing a heading node view to display changes

import { extractAttributeChanges } from '@tiptap-pro/extension-snapshot-compare'

const Heading = BaseHeading.extend({
  addNodeView() {
    return ReactNodeViewRenderer(({ node, decorations }) => {
      const { before, after, isDiffing } = extractAttributeChanges(decorations)

      return (
        <NodeViewWrapper style={{ position: 'relative' }}>
          {isDiffing && before.level !== after.level && (
            <span
              style={{
                position: 'absolute',
                right: '100%',
                fontSize: '14px',
                color: '#999',
                backgroundColor: '#f0f0f070',
              }}
              // Display the difference in level attribute
            >
              #<s>{before.level}</s>
              {after.level}
            </span>
          )}
          <NodeViewContent as={`h${node.attrs.level ?? 1}`} />
        </NodeViewWrapper>
      )
    })
  },
})

Technical details

Diff

A Diff is an object that represents a change made to the document. It contains the following properties:

PropertyTypeDescription
type'inline-insert' | 'inline-delete' | 'block-insert' | 'block-delete' | 'inline-update' | 'block-update'The type of change made
fromnumberThe start position of the change
tonumberThe end position of the change
contentFragmentThe content that was added or removed
attributionAttributionMetadata about the change, such as the user who made the change
attributes?Record<string, any>The attributes after the change; only available for 'inline-update' and 'block-update' diffs
previousAttributes?Record<string, any>The attributes before the change; only available for 'inline-update' and 'block-update' diffs

DiffSet

A DiffSet is an array of Diff objects, each corresponding to a specific change, like insertion, deletion, or update. You can iterate over the array to inspect individual changes or apply custom logic based on the diff types.

const diffsToDisplay = diffSet.filter((diff) => diff.attribution.userId === 'user-1')

// Show the filtered diffs in the editor
editor.commands.showDiff(tr, { diffs: diffsToDisplay })

Attribution

The Attribution object contains metadata about a change. It includes the following properties:

PropertyTypeDescription
type'added' | 'removed'Indicates the type of change made
userIdstring | undefinedThe ID of the user who made the change
idY.ID | undefinedThe Y.js client ID of the user who made the change

You can extend the Attribution interface to include additional properties:

declare module '@tiptap-pro/extension-snapshot-compare' {
  interface Attribution {
    userName: string
  }
}

ChangeTrackingTransform

The ChangeTrackingTransform is a class that records changes made to the document (based on ProseMirror's Transform). It represents a transform whose steps describe all of the changes made to go from one version of the document to another. It has the following properties:

PropertyTypeDescription
stepsChangeTrackingStep[]An array of steps that represent the changes made to the document
docNodeThe document after the changes have been applied
beforeNodeThe document before the changes have been applied

ChangeTrackingStep

The ChangeTrackingStep is a class that represents a single change made to the document, based on ProseMirror's ReplaceStep class. It has the following property:

PropertyTypeDescription
attributionAttributionMetadata about the change, such as the user who made it

Types

Here is the full TypeScript definition for the SnapshotCompare extension:

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    snapshotCompare: {
      /**
       * Given a change tracking transform, show the diff within the editor
       */
      showDiff: (tr: ChangeTrackingTransform, options?: { diffs?: DiffSet }) => ReturnType
      /**
       * Hide the diff and restore the previous content
       */
      hideDiff: () => ReturnType
      /**
       * Diffs two versions and renders the diff into the editor
       */
      compareVersions: <
        T extends Pick<Attribution, 'color'> & Record<string, any> = Pick<Attribution, 'color'> &
          Record<string, any>,
      >(options: {
        /**
         * The version to start the diff from
         */
        fromVersion: number
        /**
         * The version to end the diff at
         * If not provided, the latest snapshot will be used
         */
        toVersion?: number
        /**
         * Allows adding contextual data to each users changes
         */
        hydrateUserData?: (context: {
          /**
           * The type of event
           */
          type: 'added' | 'removed'
          /**
           * The userId that manipulated this content
           */
          userId: string | undefined
          /**
           * The yjs identifier of the content
           */
          id?: y.ID
        }) => T
        /**
         * If provided, allows customizing the behavior of rendering the diffs to the editor.
         * @note The default behavior would be to just display the diff immediately.
         */
        onCompare?: (
          context:
            | {
                error?: undefined
                /**
                 * The editor instance
                 */
                editor: Editor
                /**
                 * All of the changes as a transform with attribution metadata
                 */
                tr: ChangeTrackingTransform<Attribution & T>
                /**
                 * The changes represented as an array of diffs
                 */
                diffSet: DiffSet<Attribution & T>
              }
            | {
                error: Error
              },
        ) => void
        /**
         * Verbosely log the diffing process to help track down where things went wrong
         */
        enableDebugging?: boolean
      }) => ReturnType
    }
  }
}

export type SnapshotCompareOptions = {
  /**
   * The tiptap provider instance. This is required for the extension to compute the diffs, but not to display them.
   * It is also possible to pass a TiptapCollabProvider instance.
   */
  provider: TiptapCollabProvider | null
  /**
   * This allows you to control mapping of a diff into a decoration to display the content of that diff
   */
  mapDiffToDecorations?: (options: {
    /**
     * The diff to map to a decoration
     */
    diff: Diff
    /**
     * The editor instance
     */
    editor: Editor
    /**
     * The change tracking transform
     */
    tr: ChangeTrackingTransform
    /**
     * The default implementation of how to map a diff to a decoration
     */
    defaultMapDiffToDecorations: typeof defaultMapDiffToDecorations
  }) => ReturnType<typeof defaultMapDiffToDecorations>
}

export type SnapshotCompareStorageInactive = {
  /**
   * Whether the diff view is currently active
   */
  isPreviewing: false
  /**
   * The content before the diff view was applied
   */
  previousContent: null
  /**
   * The change tracking transform that was applied
   * It is currently empty because the diff view is not active
   */
  diffs: DiffSet
  /**
   * The change tracking transform that was applied
   */
  tr: null
}

export type SnapshotCompareStorageActive = {
  /**
   * Whether the diff view is currently active
   */
  isPreviewing: true
  /**
   * The content before the diff view was applied
   */
  previousContent: JSONContent
  /**
   * The change tracking transform that was applied
   */
  diffs: DiffSet
  /**
   * The change tracking transform that was applied
   */
  tr: ChangeTrackingTransform
}

export type SnapshotCompareStorage = SnapshotCompareStorageInactive | SnapshotCompareStorageActive