Tracked changes
Suggesting mode records every edit as a Word revision. Render them, accept and reject them, from the packaged review sidebar or from your own UI.
In suggesting mode every edit becomes a revision instead of a direct change. Insertions render underlined, deletions struck through, and each revision carries its author and timestamp. Revisions serialize to Word's native w:ins / w:del markup, so a document reviewed here and one reviewed in Word are interchangeable.
Requires the review module from @docx-editor.dev/pro.
Turn on suggesting
Editing mode is engine state, not a mount-time prop. Move it with the setEditingMode command:
import { DocxEditor, useEditorCommand, useEditorState } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';
const MODULES = [reviewModule()];
function SuggestToggle() {
const mode = useEditorState((s) => s.editingMode);
const suggest = useEditorCommand({ type: 'setEditingMode', mode: 'suggesting' });
const edit = useEditorCommand({ type: 'setEditingMode', mode: 'editing' });
const on = mode === 'suggesting';
return (
<button onMouseDown={(e) => e.preventDefault()} onClick={() => (on ? edit : suggest).execute()}>
{on ? '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>
);
}DocumentEditingMode is 'editing' | 'suggesting' | 'viewing'. The packaged toolbar already ships this control as the review.editingMode slot, so <DocxEditor.Toolbar /> gives you the same thing without writing the toggle.
What gets tracked
The revision model covers more than inline text:
- Text insertions and deletions, including replacements: one card, the way Word presents it.
- Paragraph structure: inserted and deleted paragraph marks, paragraph property changes.
- Tables: row and cell insert and delete, plus row, cell, and table property changes.
- Formatting: run and paragraph property changes, as their own cards.
All of these round-trip as real OOXML revisions, not editor-private state.
The review sidebar
<DocxEditorReview /> renders one card per pending decision beside the page, anchored at the change. Place it inside the viewport so it scrolls with the document rather than chasing it.
Each card is a compound you can take apart:
<DocxEditorReview>
<DocxEditorReview.List>
<DocxEditorReview.Card>
<DocxEditorReview.Avatar />
<DocxEditorReview.Author />
<DocxEditorReview.Time />
<DocxEditorReview.Summary />
<DocxEditorReview.Accept />
<DocxEditorReview.Reject />
<DocxEditorReview.Delete />
<DocxEditorReview.Replies />
<DocxEditorReview.Reply />
</DocxEditorReview.Card>
</DocxEditorReview.List>
<DocxEditorReview.Empty>Nothing to review</DocxEditorReview.Empty>
</DocxEditorReview>Markers renders one gutter marker per top-level item while the pane is closed. Clicking a marker opens the pane on that item. Balloon is the decision popover for a format or structural change. AddComment and Draft provide comment authoring.
Every part takes className, asChild, and hidden. The action parts (Accept, Reject, Delete, Reply) also take icon.
Markers accepts an icon function because one component draws every gutter marker. Return a node for an item to replace its packaged glyph, or return null or undefined to keep that glyph. The packaged glyph reflects the item kind, including a custom node's reviewCard icon when provided.
<DocxEditorReview>
<DocxEditorReview.Markers
icon={(item) => (item.kind === 'comment' ? <SpeechIcon /> : <EditIcon />)}
/>
</DocxEditorReview>An override inherits the rail's scale, offset, and visible window, so pass only the props you want to change.
Delete is the destructive one, and it is on both kinds of card: on a comment it deletes the thread, and on a tracked change it discards the suggestion. Replies draws a Delete control per reply as well, so a single answer can be taken back without deleting the conversation it belongs to. Delete is absent on a card with nothing to discard.
The stylesheet reveals it on hover of the one node it deletes, and on keyboard focus. A rail where every card offers to throw a remark away invites clicking one by mistake, and a reply that lit up its parent's control at the same time is how a reader deletes the wrong one. Restyle it like any other part; if you want it always visible, visibility: visible on [data-testid='review-delete'] is the whole override.
Choosing what the sidebar shows
The props on DocxEditorReview decide which decisions appear and how they stack:
| Prop | Default | What it does |
|---|---|---|
filter | none | Show a subset. (item) => item.kind === 'comment' puts comments in one rail and revisions in another. |
structural | false | Show "changed the document structure" cards. |
formatting | false | Show "changed text formatting" cards. |
stack | true | Push overlapping cards down instead of letting them collide. |
gap | 8 | Pixels between stacked cards. The only source of vertical spacing. |
furniture | none | Host content above the cards: filters, a legend, a summary. |
preset | true | false mounts the rail and its context only, so you lay the cards out yourself. |
structural and formatting default to off because a heavily revised document mints one card per site, and together they crowd out the decisions a reviewer reads in order. Those revisions stay marked in the page, and clicking one opens its balloon.
<DocxEditorReview filter={(item) => item.kind === 'comment'} gap={12} furniture={<MyFilters />} />With preset={false} you keep the subscription and the anchoring and render the cards yourself. useStackedReviewPositions(items, heights, { gap, scale }) is the packaged stacking math if you want the same behavior. Pass scale, the editor's render scale from editor.getRenderScale(). Card heights are CSS pixels and anchors are document points, so leaving it at its 1 default spaces the run by roughly a third too much.
Host content inside a card
useReviewItem() reads the card a child is rendered inside, so you can add a row to some cards and not others:
import { DocxEditorReview, useReviewItem } from '@docx-editor.dev/pro/react';
function AuditLink() {
const item = useReviewItem();
if (item?.kind !== 'revision') return null;
return <a href={`/audit/${item.id}`}>History</a>;
}
<DocxEditorReview>
<AuditLink />
</DocxEditorReview>;useReview
The sidebar is one rendering of this hook. Take the hook for your own list, your own card layout, or avatars from your own directory:
import { useReview } from '@docx-editor.dev/pro/react';
function ChangeList() {
const { items, activeKey, setActive, accept, reject, ready } = useReview();
if (!ready) return null;
return (
<ul>
{items.map((item) => (
<li key={item.key} data-active={item.key === activeKey || undefined}>
{/* `text` is file-derived. Render it as text, never as markup. */}
<button onClick={() => setActive(item.key)}>{item.text}</button>
<span>{item.author}</span>
{!item.readOnly && (
<>
<button onClick={() => accept(item)}>Accept</button>
<button onClick={() => reject(item)}>Reject</button>
</>
)}
</li>
))}
</ul>
);
}| Member | What it does |
|---|---|
items | Every pending decision selected by the query, in reading order. |
activeKey / setActive | Which item the caret is in; setting selects its range and scrolls to it, entering or leaving a header/footer story as the item requires. Returns whether it landed; see activatable. Takes { reveal } to choose where it lands. |
accept(item) / reject(item) | Resolve a revision. Returns whether it landed; false on an item whose readOnly is true, and on a document open for viewing. |
reply(item, text, author?) | Reply to a change. Returns whether it landed. |
remove(item) | Delete a comment thread, or discard a suggestion. Returns whether it landed. |
comment(text, author?) | Comment on the current selection. Returns whether it landed. |
selectionAnchorY | Where a comment on the selection would sit, or null. |
paneOpen / setPaneOpen | The same toggle the toolbar's comments button runs. |
ready | False while no document is loaded. |
Each item carries key, id, author, initials, date, text, replyIds, readOnly, activatable, anchorY, pageIndex, and isActive. Revision items add kind: 'revision', a revisionKind, and replacedText for replacements, so a card can say Replaced "x" with "y".
replyIds is on revisions too, not only comments. OOXML gives w:ins and w:del no body, so replying to a tracked change writes a comment over that change's range. The reply belongs inside the change's card, and the comment carries parentRevisionId naming it. A surface listing top-level cards must skip a comment with either parentId or parentRevisionId, or it will draw the reply twice.
Two things the hook gives you that you should not recompute. Items come from the document tree rather than from what is currently painted. A queue derived from the page would empty by half when the reader scrolls. And each item's anchorY comes from layout records rather than measured DOM, which would leave the sidebar a repaint behind the document and break outright during pagination.
readOnly marks a decision the engine cannot resolve structurally. Do not offer Accept and Reject on those: a card offering a button the engine will refuse is worse than one that explains why it cannot.
useReviewOf(editor, query) applies the same behavior to an editor you already hold. Filtering and activation follow these rules:
excludeRevisionKindsremoves those revision kinds from the hook'sitems.placement: falsekeeps the same metadata and setsanchorYandpageIndextonull.- A query filters returned data only. It does not change what caret-driven activation may open.
- A custom rail that hides revision kinds must also call
editor.setReviewActivationExclusions([...]).DocxEditorReviewsets these exclusions from itsstructuralandformattingprops and clears them when it unmounts. - Engine activation exclusions do not remove entries from an unfiltered queue. Those entries remain in
itemswithactivatable: false. activatableis also false when an item has no selectable range.setActivereturnsfalsewithout moving the selection when activation is excluded or no range exists. It can also returnfalsewhen the item's story cannot open.
Where an activated item lands
setActive centres an item it has to scroll to, and leaves one that is already on screen alone.
Pass reveal to choose something else:
setActive(item.key, { reveal: 'start' }); // near the top, the way a jump to a heading reads
setActive(item.key, { reveal: 'nearest' }); // the minimum scroll, so it lands against an edge
setActive(item.key, { reveal: false }); // select it, but leave the viewport alonereveal: false is for a host whose own list already drives the scroll, so the engine is not
competing with it for the viewport. It governs the item: activating a change in a header, a footer
or a note still opens that story and brings its band into view, because a story the reader cannot
see is one they cannot read the change in.
Accept or reject in bulk
There is no accept-all command; resolve the queue through the same per-item calls the cards use. Each accept resolves every site carrying that revision's (id, author, date) triple in one transaction and one undo step, so a tracked row insertion never ends up half-resolved:
function AcceptAll() {
const { items, accept } = useReview();
const actionable = items.filter((item) => !item.readOnly);
return (
<button disabled={actionable.length === 0} onClick={() => actionable.forEach(accept)}>
Accept all ({actionable.length})
</button>
);
}Detecting review content without the module
hasReviewContent on the snapshot reports whether a document carries revisions or comment anchors, and it works with no review module registered. That is the signal for telling a reader "this document has tracked changes" before you load the pro surface:
const hasReview = useEditorState((s) => s.hasReviewContent ?? false);Without a review module the editor renders revisions in their final state and offers no review UI, but it still saves them back untouched.
Interop with Word
- Saving writes standard
w:insandw:del. Word lists them in its Review pane with the same author and date, and Word's Accept/Reject resolves them. - Opening a document containing Word revisions renders them inline and in the sidebar. Nothing is flattened on load.
- Revisions in headers, footers, footnotes, and endnotes are tracked and reviewable in their own scope.
Next steps
- Comments: discussion threads alongside revisions
- Custom nodes
- Hooks:
useEditorCommandanduseEditorState
@docx-editor.dev/pro
Tracked changes, comments, and custom nodes for the React DOCX editor. Register a module on the editor root, then take the hook or the packaged sidebar.
Comments
Comment threads anchored to a range in a DOCX: read them, write them, reply and resolve, and render your own comment UI over the review hook.