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>
);
}<!-- SuggestToggle.vue -->
<script setup lang="ts">
import { useEditorCommand, useEditorState } from '@docx-editor.dev/vue';
const mode = useEditorState((state) => state.editingMode);
const suggest = useEditorCommand({ type: 'setEditingMode', mode: 'suggesting' });
const edit = useEditorCommand({ type: 'setEditingMode', mode: 'editing' });
</script>
<template>
<button
type="button"
@mousedown.prevent
@click="mode === 'suggesting' ? edit.execute() : suggest.execute()"
>
{{ mode === 'suggesting' ? 'Suggesting' : 'Editing' }}
</button>
</template><!-- Reviewer.vue -->
<script setup lang="ts">
import { DocxEditorContent, DocxEditorRoot, DocxEditorViewport } from '@docx-editor.dev/vue';
import { DocxEditorReview, reviewModule } from '@docx-editor.dev/pro/vue';
import SuggestToggle from './SuggestToggle.vue';
defineProps<{ bytes: Uint8Array }>();
const modules = [reviewModule()];
</script>
<template>
<DocxEditorRoot :document="bytes" :modules="modules" author="Jess Lin">
<SuggestToggle />
<DocxEditorViewport>
<DocxEditorContent />
<DocxEditorReview />
</DocxEditorViewport>
</DocxEditorRoot>
</template><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 mode | Engine state | Behavior |
|---|---|---|
'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:
| Case | Result |
|---|---|
setEditingMode('suggesting') | Returns { ok: false, code: 'invalidArgs' }. can reports the same reason, and the toolbar disables Suggesting. |
mode="suggesting" prop | Opens in editing mode. |
| Every case | Publishes the reason as snapshot().lastRejection. |
| First refusal per instance | Emits 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.
| Result | Meaning |
|---|---|
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
});| Option | Default | Effect |
|---|---|---|
action | Required | 'accept' or 'reject'. |
scope | 'visible' | 'visible' honors reviewer visibility and the predicate filter. 'document' includes all authors. |
keys | None | Exact 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
acceptcall 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 nokeyswhen the file must end up without them. Withscope: '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.
| Member | Behavior |
|---|---|
items | Pending decisions in reading order. |
activeKey, setActive | Read or open an item. activeKey includes caret-only activation. |
activatedKey | The explicitly activated item, or null for caret-only activation. |
accept, reject | Resolve a revision. |
adopt | Make revisions the reviewer's own. |
resolve, reopen, reply, remove, comment | Comment actions. remove also discards a suggestion. |
selectionAnchorY | Proposed comment position, or null. |
paneOpen, setPaneOpen | Read or change pane state. |
ready | false before a document loads. |
commentResolutionDisabledReason | Engine refusal for comment state actions. |
lastRefusal | Why the last review action was refused. |
| Item field group | Fields |
|---|---|
| Identity | key, id, kind, author, initials, date |
| Content | text, replyIds |
| Position | anchorY, pageIndex |
| State | readOnly, activatable, isActive |
| Revision only | revisionKind, replacedText |
| Engine data | item: 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 option | Scroll behavior |
|---|---|
| Omitted | Centers the item only when scrolling is required. |
'start' | Places the item near the viewport start. |
'nearest' | Uses the minimum scroll distance. |
false | Does 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 field | Default | Effect |
|---|---|---|
excludeRevisionKinds | None | Removes those kinds from items. Excluded insertions and deletions never pair. Excluding replace removes pairs. |
placement | true | false skips layout and sets anchors to null. |
pairReplacements | true | false 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 API | Returns |
|---|---|
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.
| Value | Meaning |
|---|---|
scale | CSS pixels per layout point, including zoom. |
top | CSS pixels from the scroll container's top padding edge to the first page. |
left | CSS 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. |
compact | true when the viewport is too narrow for a full column. |
compactCardLeft | The 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: absoluteand a fixed width. - It is a child of
DocxEditor.Viewport, with no positioned element in between. - It uses
visibility: hidden, notdisplay: none, whileleftisnull.
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>
);
}| View | reviewDisplayMode | Office.js RevisionsMarkup | Office.js RevisionsView |
|---|---|---|---|
| Simple Markup | 'simple-markup' | Simple | Final |
| All Markup | 'all-markup' | All | Final |
| No Markup | 'proposed' | None | Final |
| Original | 'original' | None | Original |
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;
}<script setup lang="ts">
import { watch } from 'vue';
import { useDocxEditor } from '@docx-editor.dev/vue';
const CUTOFF = Date.parse('2026-02-01T00:00:00Z');
const editor = useDocxEditor();
watch(
editor,
(current, _previous, onCleanup) => {
if (!current) return;
current.setTrackedChangesFilter((revision) => {
const timestamp = Date.parse(revision.date ?? '');
return (
revision.author === 'Jess' &&
revision.revisionKind === 'insert' &&
Number.isFinite(timestamp) &&
timestamp >= CUTOFF
);
});
onCleanup(() => current.setTrackedChangesFilter(null));
},
{ immediate: true }
);
</script>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, moveTo | Content stays as ordinary text. | Content is omitted. |
delete, moveFrom | Content is omitted. | Content returns as ordinary text. |
paragraphMark, inserted or deleted row | Accepted structure. | Original structure. |
format, other property changes | Current values without markup. | Current values without markup. |
- Keep the predicate synchronous, deterministic, and free of side effects. The editor caches its decisions.
- Call
setTrackedChangesFilteragain 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:tblPrChangestill renders. - A formatting change records the old properties in
w:rPrChangeorw: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:doNotTrackFormattinginsettings.xmlstops 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,
Deleteremoves 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.