Tracked changes

Record DOCX revisions in suggesting mode, then accept, reject, filter, and navigate them from code.

Suggesting mode records edits as standard w:ins and w:del revisions with author and date. You read, resolve, and navigate them through useReview() and editor commands.

This feature requires the review module from @docx-editor.dev/pro and an EigenPal Pro License.

Record tracked changes

The following example mounts the review module with an author and adds a button that switches between editing and suggesting:

import { DocxEditor, useEditorCommand, useEditorState } from '@docx-editor.dev/react';
import { DocxEditorReview, reviewModule } from '@docx-editor.dev/pro/react';

const MODULES = [reviewModule()];

function SuggestToggle() {
  const mode = useEditorState((state) => state.editingMode);
  const suggest = useEditorCommand({ type: 'setEditingMode', mode: 'suggesting' });
  const edit = useEditorCommand({ type: 'setEditingMode', mode: 'editing' });
  const active = mode === 'suggesting';

  return (
    <button
      onMouseDown={(event) => event.preventDefault()}
      onClick={() => (active ? edit : suggest).execute()}
    >
      {active ? 'Suggesting' : 'Editing'}
    </button>
  );
}

export function Reviewer({ bytes }: { bytes: Uint8Array }) {
  return (
    <DocxEditor.Root document={bytes} modules={MODULES} author="Jess Lin">
      <SuggestToggle />
      <DocxEditor.Viewport>
        <DocxEditor.Content />
        <DocxEditorReview />
      </DocxEditor.Viewport>
    </DocxEditor.Root>
  );
}

<DocxEditorReview /> renders one card per pending decision. Place it inside the viewport so it scrolls with the document. For its props and parts, see Comments and review cards.

Root modeEngine stateBehavior
'edit''editing'Applies edits without revisions.
'suggesting''suggesting'Records supported edits as revisions.
'view''viewing'Keeps the document read-only.

If you omit mode, the root honors the document's w:trackRevisions setting when a review module and an author are present. An explicit mode overrides it. The <DocxEditor> convenience component defaults to mode="edit".

Suggesting needs an author

A revision records who proposed it, so suggesting mode needs an author. Without one, the editor refuses the request:

CaseResult
setEditingMode('suggesting')Returns { ok: false, code: 'invalidArgs' }. can reports the same reason, and the toolbar disables Suggesting.
mode="suggesting" propOpens in editing mode.
Every casePublishes the reason as snapshot().lastRejection.
First refusal per instanceEmits the error event with code suggestingNeedsAuthor and logs once to the console.

Set the author at mount, with setAuthor, or through the author prop. When it arrives, a pending request enters suggesting mode unless the reader already chose a mode. A setEditingMode call, including one from onReady, counts as a reader choice.

If you remove the author while mode="suggesting" is active, the editor returns to editing mode and publishes the reason, unless protection forbids editing. Suggesting adopted from the document or set through setEditingMode stays active, refuses edits, and publishes the reason until the author returns.

Accept or reject a change

useReview() returns accept and reject. Each takes one review item and returns a Boolean:

const { items, accept, reject } = useReview();
const first = items.find((item) => item.kind === 'revision' && !item.readOnly);
if (first && !accept(first)) console.warn('The editor refused the decision.');

Without an adapter, call editor.acceptReviewItem(key) or editor.rejectReviewItem(key). They return an ExecResult.

ResultMeaning
true or { ok: true }Every location of the item resolved in one transaction and one undo step. Insertions and deletions match on (id, author, date); formatting changes match on their own key.
false or { ok: false }The item is readOnly, the editor is in viewing mode, or the engine refused. Read useReview().lastRefusal.

Formatting decisions can share an OOXML revision ID. Each has its own key and resolves independently.

useReviewRefusalMessage() turns lastRefusal into a translated sentence. For each code, see Diagnostics.

Resolve a batch

Run the resolveAllReviewChanges command to accept or reject many changes in one undo step:

const result = editor.exec({
  type: 'resolveAllReviewChanges',
  action: 'accept', // or 'reject'
  scope: 'visible', // or 'document' for all authors
  unsupported: 'skip', // or 'fail' to require every change to resolve
});
OptionDefaultEffect
actionRequired'accept' or 'reject'.
scope'visible''visible' honors reviewer visibility and the predicate filter. 'document' includes all authors.
keysNoneExact keys from getReviewItems(). Overrides scope.
unsupported'skip''fail' refuses the whole batch if one change cannot resolve.

Review > Accept all changes shown and Reject all changes shown run the same command. Skipped changes stay pending and appear in the reviewChangesResolved event's skipped list. For result codes, see Resolve changes shown in the editor.

A loop over useReview().items differs from the command:

  • Each accept call is its own undo step.
  • It reaches only listed items, so filtered and hidden changes stay pending.
  • It never reaches formatting changes that leave the resolved formatting unchanged. These have no card. Run the command with scope: 'document' and no keys when the file must end up without them. With scope: 'visible', the command keeps those of hidden authors, and all of them while a tracked change predicate is set.

Read and reveal changes

useReview() returns the same data and actions as the packaged rail. React returns plain values. Vue returns computed refs for the state fields.

MemberBehavior
itemsPending decisions in reading order.
activeKey, setActiveRead or open an item. activeKey includes caret-only activation.
activatedKeyThe explicitly activated item, or null for caret-only activation.
accept, rejectResolve a revision.
adoptMake revisions the reviewer's own.
resolve, reopen, reply, remove, commentComment actions. remove also discards a suggestion.
selectionAnchorYProposed comment position, or null.
paneOpen, setPaneOpenRead or change pane state.
readyfalse before a document loads.
commentResolutionDisabledReasonEngine refusal for comment state actions.
lastRefusalWhy the last review action was refused.
Item field groupFields
Identitykey, id, kind, author, initials, date
Contenttext, replyIds
PositionanchorY, pageIndex
StatereadOnly, activatable, isActive
Revision onlyrevisionKind, replacedText
Engine dataitem: item.ranges for revisions, item.range for comments

Render item.text and replacedText as text, never as markup. The document controls these values. Hide Accept and Reject when readOnly is true.

  • Adjacent insertions, deletions, or matching formatting changes from one author form one decision, even across editing times.
  • Unchanged text separates decisions, even when their ID, author, and date match.

A reply to a revision is a comment with parentRevisionId. Omit comments with parentId or parentRevisionId from top-level lists.

To open an item, call setActive(key, options). It moves the caret to the item start and opens its story. It returns false when activation is excluded, the item has no range, or its story cannot open.

reveal optionScroll behavior
OmittedCenters the item only when scrolling is required.
'start'Places the item near the viewport start.
'nearest'Uses the minimum scroll distance.
falseDoes not scroll.

Pass { announce: true } to also emit reviewItemReveal, which opens the packaged card or balloon.

Query items

Pass a query to useReview(query), useReviewOf(editor, query), or editor.getReviewItems(query):

Query fieldDefaultEffect
excludeRevisionKindsNoneRemoves those kinds from items. Excluded insertions and deletions never pair. Excluding replace removes pairs.
placementtruefalse skips layout and sets anchors to null.
pairReplacementstruefalse lists both halves of a replacement.

useReviewOf takes Editor | null in React. In Vue, it takes Ref<Editor | null> and accepts a reactive query.

A query filters returned data only. To stop the caret from activating hidden kinds, call setReviewActivationExclusions. DocxEditorReview sets those exclusions from its structural and formatting props.

Show a replacement as one item

Typing over a selection records a deletion and an insertion. The default query lists them as one 'replace' item:

for (const entry of editor.getReviewItems()) {
  if (entry.kind === 'revision' && entry.revisionKind === 'replace') {
    console.log(`"${entry.replacedText}" -> "${entry.text}"`);
  }
}

Two revisions pair when they are adjacent in the queue, in either order, share author, nesting level, and readOnly, and touch at the same paragraph offset. Accept, reject, activation, replies, and getReviewItemRects act on both halves. A reply anchors its comment over the inserted text. The pair key stays stable while both halves are pending.

The packaged DocxEditorReview rail reads with pairReplacements: false, so the pane lists the halves as separate cards.

Step through changes

Run navigateReviewChange to move in the editor's own order:

const result = editor.exec({ type: 'navigateReviewChange', direction: 'next' });
if (!result.ok) showMessage(result.reason);

It stops once per replacement, wraps at the end, and honors reviewer and predicate filters. Each step emits reviewItemReveal with source: 'navigate'.

Handle reveal events

Listen for reviewItemReveal to open your own card or balloon:

import { useEditorEvent } from '@docx-editor.dev/react';

useEditorEvent('reviewItemReveal', ({ key, pairKey, source }) => {
  openBalloon(pairKey ?? key);
});

The event key names one half of a replacement. pairKey names the pair in the default list, so open pairKey ?? key. Caret movement, page clicks, and remote edits do not emit this event.

Host APIReturns
getReviewItemsAt(clientX, clientY, query?)Items under a point, narrowest range first, each with placement and rect. Ranges without characters, such as paragraph marks and comments on empty ranges, never match.
getReviewItemRects(key)The item's painted bands in client coordinates, or an empty array.
getActivatedReviewItemKey(query?)The explicitly activated item's key, or null.
on('reviewItemReveal', handler)Each explicit reveal, as { key, pairKey, source }.

Use the same query for activation and item reads. To pick the items a custom sidebar lists, apply reviewPaneListsItem(revisionsIn, item) from @docx-editor.dev/core/editor to an unfiltered getReviewItems() result.

Place custom cards

anchorY is in layout points from the top of the first page. useReviewRailMetrics() converts it to CSS pixels: a card's top is metrics.top + position * metrics.scale.

ValueMeaning
scaleCSS pixels per layout point, including zoom.
topCSS pixels from the scroll container's top padding edge to the first page.
leftCSS pixels from the scroll container's left padding edge to the column. null before layout.
side'right', or 'left' in a right-to-left interface.
compacttrue when the viewport is too narrow for a full column.
compactCardLeftThe left of a 300-pixel card that stays inside a narrow viewport.

To stack cards, pass measured heights to useStackedReviewPositions(items, heights, { gap, scale }). The active card stays at its anchor, and the cards before it move up. Inside <DocxEditorReview preset={false}>, call useReviewRailMetrics() without arguments. For a complete example, see Custom cards in the review rail.

For a column of your own, pass a ref to useReviewRailMetrics(ref). The column element must meet these conditions:

  • The calling component renders it with position: absolute and a fixed width.
  • It is a child of DocxEditor.Viewport, with no positioned element in between.
  • It uses visibility: hidden, not display: none, while left is null.

The mounted column reserves the review gutter. Pass { reserveGutter: false } when DocxEditorReview already reserves it.

To serve Add comment requests from your column, read ReviewRailContext from the adapter. Call register() to show the controls and registerCommentDraft(handler) to receive requests. register() also reserves the gutter, so pass { reserveGutter: false } to useReviewRailMetrics:

useEffect(() => {
  if (!registry || !editor || viewing) return undefined;
  const unregister = registry.register();
  const release = registry.registerCommentDraft(() => {
    const anchorY = editor.getSelectionPlacement()?.anchorY;
    const pin = editor.retainSelection();
    if (anchorY === undefined || !pin) return;
    setPaneOpen(true);
    openDraft(anchorY, pin); // Call editor.releaseSelection(pin) when the draft closes.
  });
  return () => {
    release();
    unregister();
  };
}, [registry, editor, viewing, setPaneOpen, openDraft]);

In Vue, inject(ReviewRailContext, null) returns a ref. Register inside watchEffect and clean up with onCleanup. Register only while your column can open a draft, because a request counts as served once a handler runs.

Switch the review display

Drive the review.simpleMarkup, review.allMarkup, review.noMarkup, or review.original slot with useEditorCommand:

import { useEditorCommand, useEditorState } from '@docx-editor.dev/react';

function SimpleMarkupButton() {
  const view = useEditorState((state) => state.reviewDisplayMode);
  const simple = useEditorCommand('review.simpleMarkup');
  return (
    <button onClick={simple.execute} aria-pressed={view === 'simple-markup'}>
      Simple Markup
    </button>
  );
}
ViewreviewDisplayModeOffice.js RevisionsMarkupOffice.js RevisionsView
Simple Markup'simple-markup'SimpleFinal
All Markup'all-markup'AllFinal
No Markup'proposed'NoneFinal
Original'original'NoneOriginal

Display modes change the view only. They never accept, reject, or remove saved revisions. For marks, colors, and change bars, see Customize revision markup.

Filter changes

Filters are view-only. They hide markup and cards, but saving writes the same revision markup as with no filter.

Filter by reviewer

Hide or show authors from code:

const authors = editor.getReviewAuthors();
editor.setReviewAuthorVisible('Ada', false);
editor.isReviewAuthorVisible('Ada'); // false
editor.showAllReviewAuthors();

A hidden author's revisions render as accepted, and their comments hide. setAllReviewAuthorsVisible(visible) changes every author. The packaged menu exposes the same list in Review > Markup Options > Reviewers.

Filter with a predicate

Pass a TrackedChangePredicate to setTrackedChangesFilter. Clear it when the owning component unmounts:

import { useEffect } from 'react';
import { useDocxEditor } from '@docx-editor.dev/react';

const CUTOFF = Date.parse('2026-02-01T00:00:00Z');

export function RecentJessInsertions() {
  const editor = useDocxEditor();

  useEffect(() => {
    if (!editor) return;
    editor.setTrackedChangesFilter((revision) => {
      const timestamp = Date.parse(revision.date ?? '');
      return (
        revision.author === 'Jess' &&
        revision.revisionKind === 'insert' &&
        Number.isFinite(timestamp) &&
        timestamp >= CUTOFF
      );
    });
    return () => editor.setTrackedChangesFilter(null);
  }, [editor]);

  return null;
}

The predicate receives one ReviewRevisionItem per decision, with author, date, revisionKind, text, replacedText, ranges, and readOnly. date is optional raw OOXML, so validate it with Number.isFinite. Return true to keep the revision tracked.

Pass a TrackedChangeFilterMode as the second argument to choose how excluded revisions render:

Revision kind'accept' (default)'reject'
insert, moveToContent stays as ordinary text.Content is omitted.
delete, moveFromContent is omitted.Content returns as ordinary text.
paragraphMark, inserted or deleted rowAccepted structure.Original structure.
format, other property changesCurrent values without markup.Current values without markup.
  • Keep the predicate synchronous, deterministic, and free of side effects. The editor caches its decisions.
  • Call setTrackedChangesFilter again after captured state changes, even with the same function.
  • If the predicate throws, the editor keeps the last complete projection and surfaces the error.
  • A revision stays tracked only when its author is visible and the predicate returns true.
  • Hidden authors always use the accepted projection, even in 'reject' mode.
  • The predicate runs for revisions, not comments.

Adopt suggestions as your own

adopt(items, options?) from useReview() makes pending revisions record the reviewer as their author. The changes stay pending:

const { items, adopt } = useReview();
const suggested = items.filter((item) => item.kind === 'revision' && item.author === 'Agent');
adopt(suggested); // or adopt(item, { author: 'Ada Lovelace', date: new Date() })

adopt ignores comment items and changes every revision in one undo step. It returns false and changes nothing when no revision is passed, no author is known, or the editor refuses the write. Viewing mode and document protection refuse it.

Review keys include the author, so each adopted change gets a new key. For author filters, run the setReviewChangesAuthor command. See Change authors in the editor.

Detect review content without Pro

The hasReviewContent snapshot field detects revisions and comment anchors without the review module:

const hasReview = useEditorState((state) => state.hasReviewContent ?? false);

Without the module, the editor renders revisions in their final state and keeps the revision markup on save.

Behavior notes

  • Tracked: text insertions, deletions, and replacements; paragraph marks; paragraph properties; run formatting; table rows and cells; image insertion and deletion.
  • Not tracked: list changes, indent level, tab stops, and table property edits. These apply with no card. An existing w:tblPrChange still renders.
  • A formatting change records the old properties in w:rPrChange or w:pPrChange. Repeated edits on one run keep one record of its original properties. Rejecting restores them.
  • Restoring the properties in your own record removes it. Restoring the properties in another author's record keeps it, so accept or reject it instead.
  • Turning bold off writes an explicit off value. Toggling bold twice can therefore leave a formatting change.
  • Formatting inside your own pending insertion records nothing. Formatting inside another author's insertion records your own change.
  • w:doNotTrackFormatting in settings.xml stops formatting records. Text edits stay tracked.
  • A formatting change that leaves the resolved formatting unchanged has no card and no change bar.
  • A tracked edit over another author's change of that kind refuses with edit.tracked-foreign-revision-unsupported.
  • On a comment card, Delete removes the thread. On a tracked-change card, it discards the suggestion.
  • In viewing mode, readers can select and copy text, and links work. Every edit is refused.
  • Tracked-changes protection requires suggesting mode. Forms and comments-only protection refuse it, and read-only protection opens in viewing mode.
  • Turning on forms protection while suggesting switches to editing mode and sets lastRejection. See Document protection.
  • Change bars mark changed lines in body, tables, headers, footers, notes, and text boxes. A click on a bar toggles Simple Markup and All Markup.
  • Headers, footers, footnotes, and endnotes track changes in their own scopes. Files saved here resolve in other OOXML editors.

For format-level detail, see DOCX fidelity.

See also