Tracked changes
Customize DOCX editor tracked changes: review sidebar, tracked change balloons, author colors, text marks, and review controls.
Choose a review sidebar or tracked change balloons beside the changed text. Then set author colors, text marks, and the appearance of the review controls. Each section includes a contract preview and React and Vue examples.
Choose where tracked changes appear
The review module shows comments and tracked changes in a review pane beside the page. Four settings change that display. You can combine them, and you can change them while the editor runs.
| Setting | Values | Default | What it controls |
|---|---|---|---|
revisionsIn | pane, balloons | pane | Where tracked changes open. |
commentMarkers | initials, icon | icon | How a collapsed comment marker looks. |
opening | auto, manual | auto | When the review pane opens by itself. |
overflow | float, shrinkPage, scroll | float | What the open pane does when it does not fit the page. |
The settings belong to one editor instance. The editor never saves them into the document or shares them with other participants. They stay in force when you load another document. Review controls require an EigenPal Pro License.
Understand the default display
With no settings, the review pane lists comments and tracked changes as cards beside the text that they belong to. The pane opens when a document with review items loads, and when you make a tracked change while it is closed. A collapsed comment marker shows the comment icon. Set commentMarkers: 'initials' to show the thread author’s initials. Clicking text with an open comment opens the review pane. Selecting text leaves the pane closed.
The following example mounts the default display. DocxEditorReview renders the pane, its cards, its markers, and its tracked change balloons, so every example on this page includes it.
import { DocxEditor } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';
const modules = [reviewModule()];
export function ReviewEditor({ bytes }: { bytes: Uint8Array }) {
return (
<DocxEditor.Root document={bytes} modules={modules} author="Jess Lin">
<DocxEditor.Toolbar />
<DocxEditor.Viewport style={{ height: 520 }}>
<DocxEditor.Content />
<DocxEditorReview />
</DocxEditor.Viewport>
</DocxEditor.Root>
);
}<script setup lang="ts">
import { DocxEditor } from '@docx-editor.dev/vue';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/vue';
defineProps<{ bytes: Uint8Array }>();
const modules = [reviewModule()];
</script>
<template>
<DocxEditor.Root :document="bytes" :modules="modules" author="Jess Lin">
<DocxEditor.Toolbar />
<DocxEditor.Viewport :style="{ height: '520px' }">
<DocxEditor.Content />
<DocxEditorReview />
</DocxEditor.Viewport>
</DocxEditor.Root>
</template>Set the starting display
Pass the settings to reviewModule through pane. Omit a setting to keep its default. Create the module list once, outside the component, so that the editor does not receive a new module on each render.
import { reviewModule, type ReviewPaneOptions } from '@docx-editor.dev/pro/react';
const pane: ReviewPaneOptions = { revisionsIn: 'balloons', commentMarkers: 'initials' };
const modules = [reviewModule({ pane })];import { reviewModule, type ReviewPaneOptions } from '@docx-editor.dev/pro/vue';
const pane: ReviewPaneOptions = { revisionsIn: 'balloons', commentMarkers: 'initials' };
const modules = [reviewModule({ pane })];The editor checks each value. An unknown setting or value throws a TypeError when you call reviewModule, so a misspelled setting does not go unnoticed.
Use tracked change balloons
With revisionsIn: 'balloons', the review pane lists comments only. Each tracked change opens in a tracked change balloon at its text. These actions open a tracked change balloon:
- A click on the tracked change.
- Next Change or Previous Change.
- A call to
setActiveReviewItem(key, { announce: true }).
Moving the caret into a change does not open its tracked change balloon. The tracked change balloon shows the author, the date, and the changed words. It lists replies, has a reply field, and has Accept and Reject buttons. A deletion followed by an insertion from the same author appears as one replacement.
Escape, typing in the document, or a click elsewhere on the page closes the tracked change balloon. A tracked change balloon that Next Change opens takes the keyboard focus, so the user can press Tab to reach Accept and Reject. Tracked change balloons open only in All Markup.
With tracked change balloons, a document that has tracked changes and no comments does not open the pane. Switching to 'balloons' closes an open pane that has nothing left to list.
const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];To style the tracked change balloon or to replace it with your own component, see Style tracked change balloons.
Choose the comment marker style
A collapsed comment marker marks a comment thread beside the page, for example while the pane is closed.
'initials': a badge with the thread author's initials in the author's color. A small count shows the number of replies, and a check mark replaces the initials when the thread is resolved. Each badge is a miniature of its thread, so the user can see who commented without opening the pane.'icon': the comment icon. Use it when initials do not identify your users, such as for anonymous reviewers.
The DOCX editor shows at most three characters from a comment's saved initials. If saved initials are absent, it uses the first letters of the author's first two name parts. An empty author name shows ?.
The built-in initials badge has a maximum width of 36 pixels. Long initials stay inside the badge, while the reply count stays outside it. Dark mode preserves the badge border when you move the pointer over the marker.
const modules = [reviewModule({ pane: { commentMarkers: 'icon' } })];The author color comes from the author color palette or from a DocxEditor.AuthorStyle declaration. For more information, see Tracked changes. To draw your own marker, pass an icon function to DocxEditorReview.Markers. That function takes precedence over both values. For more information, see Comments and review cards.
Keep the pane closed until the user opens it
With opening: 'manual', the pane stays closed until your application or the user opens it. The markers still show where each comment is. Next Change and Previous Change move through the changes without opening the pane.
const modules = [reviewModule({ pane: { opening: 'manual' } })];The user opens the pane with the Comments & Changes toolbar button (DocxEditor.Toolbar.Comments). To open it from your application, call setPaneOpen(true) from useReview(), or run the toggleReviewPane command. Read the open state from paneOpen in useReview(), or from snapshot().reviewPaneOpen.
Choose what happens when the pane does not fit
The open pane needs a 316-pixel card column beside the page. overflow decides what happens when the viewport is too narrow for the page and the column at the page's zoom.
| Value | The page | The review column |
|---|---|---|
'float' | Keeps its size. | Changes to a marker strip. The open card floats over the page. |
'shrinkPage' | Shrinks under a fit zoom, down to minZoom. | Stays full width beside the page while a scale exists that fits. |
'scroll' | Keeps the size it has while the pane is closed. | Stays full width. The viewport scrolls sideways to reach the cards. |
Under 'scroll', the document and the review column scroll together. The page does not change size when the pane opens or closes, so the document does not lay out again.
A fixed zoom has no room to give, so 'shrinkPage' acts as 'float' with a fixed zoom. A fit zoom with no maximum always shows the full column, because the page fills the width that remains. For more information, see Make room for the review pane.
overflow applies to the review pane only. The navigation pane stays docked at its side in every mode. It doesn't scroll sideways with the document, and it takes its room beside the page in the same way as when no review pane is mounted.
const modules = [reviewModule({ pane: { overflow: 'scroll' } })];Combine display settings
Each setting controls one part of the display, so you can combine them freely. The following examples show common combinations.
Review tracked changes beside the text
Use this combination when reviewers decide on changes at the text and discuss in comment threads. The pane lists comments with initials badges, and each tracked change opens in a tracked change balloon at its text.
const modules = [reviewModule({ pane: { revisionsIn: 'balloons', commentMarkers: 'initials' } })];Keep the sidebar closed by default
Use this combination when the document needs the most room. The page shows initials badges for comments and tracked change balloons for tracked changes. The pane opens only when the user asks for it.
const modules = [
reviewModule({
pane: { opening: 'manual', revisionsIn: 'balloons', commentMarkers: 'initials' },
}),
];Keep a fixed page size
Use this combination when the page must keep one size, such as for a layout that lines up with other content. The review column stands beside the page, and the user scrolls sideways to it.
const modules = [reviewModule({ pane: { overflow: 'scroll' } })];Use comment icons instead of initials
Use this combination when author names are not meaningful to the reader. Comment markers show the comment icon, and tracked changes stay in the pane.
const modules = [reviewModule({ pane: { commentMarkers: 'icon' } })];Change the display at runtime
Call setReviewPaneOptions on the editor instance with only the settings that change. The other settings keep their values. This method sets how the pane behaves. To open or close the pane itself, use setPaneOpen from useReview(). Read the settings in force from snapshot().reviewPane. DocxEditorReview reads the same settings, so the pane, the markers, and the tracked change balloons update at once.
setReviewPaneOptions returns an ExecResult:
{ ok: true, changed: false }when the editor accepts the settings.changedanswers for the document, and a display setting never changes the document. To detect a settings change, compare thesnapshot().reviewPanereference, which changes only when a setting changes.{ ok: false, code: 'invalidArgs' }for an unknown setting or value.{ ok: false, code: 'unsupported' }when no review module is registered.
A refused call changes nothing.
The following component lets the user choose where tracked changes open and how comment markers look. Mount it anywhere under DocxEditor.Root. Each button prevents the default action of a mouse press, so the caret stays in the document.
import type { MouseEvent } from 'react';
import { useDocxEditor, useEditorState } from '@docx-editor.dev/react';
import type { CommentMarkerStyle, RevisionDisplay } from '@docx-editor.dev/pro/react';
const keepCaret = (event: MouseEvent) => event.preventDefault();
export function ReviewDisplaySwitch() {
const editor = useDocxEditor();
const revisionsIn = useEditorState((snapshot) => snapshot.reviewPane.revisionsIn);
const markers = useEditorState((snapshot) => snapshot.reviewPane.commentMarkers);
function showChangesIn(next: RevisionDisplay) {
const result = editor?.setReviewPaneOptions({ revisionsIn: next });
if (result && !result.ok) console.warn('Review display refused:', result.code);
}
function showMarkers(next: CommentMarkerStyle) {
editor?.setReviewPaneOptions({ commentMarkers: next });
}
return (
<div role="group" aria-label="Review display">
<button
type="button"
aria-pressed={revisionsIn === 'pane'}
onMouseDown={keepCaret}
onClick={() => showChangesIn('pane')}
>
Changes in pane
</button>
<button
type="button"
aria-pressed={revisionsIn === 'balloons'}
onMouseDown={keepCaret}
onClick={() => showChangesIn('balloons')}
>
Tracked change balloons
</button>
<button
type="button"
aria-pressed={markers === 'initials'}
onMouseDown={keepCaret}
onClick={() => showMarkers(markers === 'initials' ? 'icon' : 'initials')}
>
Initials on markers
</button>
</div>
);
}<script setup lang="ts">
import { useDocxEditor, useEditorState } from '@docx-editor.dev/vue';
import type { CommentMarkerStyle, RevisionDisplay } from '@docx-editor.dev/pro/vue';
const editor = useDocxEditor();
const revisionsIn = useEditorState((snapshot) => snapshot.reviewPane.revisionsIn);
const markers = useEditorState((snapshot) => snapshot.reviewPane.commentMarkers);
function showChangesIn(next: RevisionDisplay) {
const result = editor.value?.setReviewPaneOptions({ revisionsIn: next });
if (result && !result.ok) console.warn('Review display refused:', result.code);
}
function showMarkers(next: CommentMarkerStyle) {
editor.value?.setReviewPaneOptions({ commentMarkers: next });
}
</script>
<template>
<div role="group" aria-label="Review display">
<button
type="button"
:aria-pressed="revisionsIn === 'pane'"
@mousedown.prevent
@click="showChangesIn('pane')"
>
Changes in pane
</button>
<button
type="button"
:aria-pressed="revisionsIn === 'balloons'"
@mousedown.prevent
@click="showChangesIn('balloons')"
>
Tracked change balloons
</button>
<button
type="button"
:aria-pressed="markers === 'initials'"
@mousedown.prevent
@click="showMarkers(markers === 'initials' ? 'icon' : 'initials')"
>
Initials on markers
</button>
</div>
</template>useEditorState re-renders the component only when the selected value changes, so a bold toggle or a caret move does not re-render the switch.
Open the pane from your application
opening decides when the pane opens by itself. To open or close it from your own control, use useReview():
import { useReview } from '@docx-editor.dev/pro/react';
export function ReviewPaneButton() {
const { paneOpen, setPaneOpen } = useReview();
return (
<button
type="button"
aria-pressed={paneOpen}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setPaneOpen(!paneOpen)}
>
Review pane
</button>
);
}<script setup lang="ts">
import { useReview } from '@docx-editor.dev/pro/vue';
const { paneOpen, setPaneOpen } = useReview();
</script>
<template>
<button type="button" :aria-pressed="paneOpen" @mousedown.prevent @click="setPaneOpen(!paneOpen)">
Review pane
</button>
</template>Set tracked change colors and marks
Use revisionMarkup for marks, named colors, background highlights, changed-line bars, and cell shading. Use AuthorStyle for exact author colors and avatars.
Use ColorByChangeType to color unmatched insertions and deletions by kind. Author declarations take precedence for matched authors.
Viewer preferences stay outside the DOCX file and collaboration state. trackFormatting also controls future formatting revisions. Keep text marks so color is not the only indicator.
Markup option reference
Use these viewer preferences to customize tracked changes in your DOCX editor. Each text category accepts mark, color, and background.
| Category | Default mark | Default color | Default background |
|---|---|---|---|
insertions | underline | byAuthor | none |
deletions | strikethrough | byAuthor | none |
movedFrom | doubleStrikethrough | green | none |
movedTo | doubleUnderline | green | none |
formatting | none | byAuthor | none |
Text marks accept none, colorOnly, bold, italic, underline, doubleUnderline, strikethrough, and doubleStrikethrough. Deletion and moved-from marks also accept hidden, caret, and pound. A hidden deletion remains a pending change. It still needs an accessible review action.
Colors accept auto, byAuthor, or a named markup color. Backgrounds accept none, byAuthor, or a named markup color. Named colors are black, blue, turquoise, green, pink, red, yellow, white, darkBlue, teal, darkGreen, violet, darkRed, darkYellow, gray50, gray25, lightBlue, lightYellow, lightOrange, lightPurple, lightGreen, and gray.
| Option | Values and defaults |
|---|---|
changedLines.mark | none, leftBorder, rightBorder, or outsideBorder. Default: outsideBorder. |
changedLines.color | auto, byAuthor, or a named color. Default: auto. |
cells.inserted | none, byAuthor, or a named color. Default: lightBlue. |
cells.deleted | Same color choices. Default: pink. |
cells.merged | Same color choices. Default: lightYellow. |
cells.split | Same color choices. Default: lightOrange. |
trackMoves | Boolean. Default: true; false uses deletion and insertion presentation for move marks. |
trackFormatting | Boolean. Default: true; also controls the creation of future formatting revisions. |
The markup setting does not make an unsupported tracked operation available. Keep the engine's refusal behavior for unsupported edits.
Controlled and runtime settings
Pass revisionMarkup to Root when application state controls the complete viewer configuration. Save proposed changes from onRevisionMarkupChange back into that prop. The callback also receives changes proposed by the markup dialog or API. Without that prop, the editor keeps its own preferences.
setRevisionMarkup merges omitted fields with the existing preferences. Controlled prop reconciliation resolves omitted values from the defaults. Choose one configuration owner to prevent application renders from reversing user changes.
Review display modes include simple-markup, all-markup, no-markup, and original. A display mode changes the view. It does not accept or reject a tracked change. Use the review controls to make document decisions.
Author declaration reference
DocxEditor.AuthorStyle matches an exact author name in the document. Its changes apply while the declaration is mounted. Removing the declaration restores the remaining author presentation.
| Prop | Use |
|---|---|
author | Exact author name to match. |
color | Document mark color and review interface accent. |
background | Resting tracked text background. |
activeBackground | Active tracked change range background. |
spanClassName | Application class on painted tracked text spans. |
avatarUrl | Application-approved author avatar URL. |
Revision CSS property reference
| Properties | Use |
|---|---|
--doc-revision-insertion, --doc-revision-deletion | Change-type colors under ColorByChangeType. |
--doc-revision-insertion-wash, --doc-revision-deletion-wash | Resting tracked text backgrounds. |
--doc-revision-insertion-active-bg, --doc-revision-insertion-active-border | Active insertion highlight. |
--doc-revision-deletion-active-bg, --doc-revision-deletion-active-border | Active deletion highlight. |
--doc-revision-other-active-bg | Active formatting or structural change highlight. |
--doc-revision-format, --doc-revision-format-wash | Formatting change presentation. |
--doc-revision-insertion-decoration-style | Underline style, such as solid or dotted. |
--doc-revision-insertion-decoration-thickness | Underline thickness. Default: 0.06em. |
--doc-revision-insertion-underline-offset | Underline distance. Default: 0.15em. |
--doc-review-change-bar, --doc-review-change-bar-width | Standard change bar color and width. Default width: 1px. |
--doc-review-change-bar-simple, --doc-review-change-bar-simple-width | Simple markup bar color and width. Default width: 2px. |
--doc-review-author-0 through --doc-review-author-7 | Shared author palette. |
--doc-revision-color-<name> | On-screen value for a named markup color. |
CSS properties affect the on-screen editor. They do not replace the exporter's named print colors.
React and Vue markup reference
Pass your DOCX bytes and loaded fonts to this component. The preview uses a fictional contract.
import { DocxEditor, type DocxEditorRootProps } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';
import '@docx-editor.dev/core/styles/editor.css';
const modules = [reviewModule()];
export function ContractEditor({
bytes,
fonts,
}: {
bytes: Uint8Array;
fonts: DocxEditorRootProps['fonts'];
}) {
return (
<div className="docx-editor contract-workspace">
<DocxEditor.Root
document={bytes}
fonts={fonts}
modules={modules}
author="Demo reviewer"
mode="suggesting"
reviewDisplayMode="all-markup"
revisionMarkup={{
insertions: { mark: 'underline', color: 'darkGreen', background: 'lightGreen' },
deletions: { mark: 'strikethrough', color: 'darkRed', background: 'pink' },
}}
>
<DocxEditor.ColorByChangeType />
<DocxEditor.Viewport style={{ height: 520 }}>
<DocxEditor.Content />
<DocxEditor.HyperLink />
<DocxEditor.ContextMenu />
<DocxEditorReview />
</DocxEditor.Viewport>
</DocxEditor.Root>
</div>
);
}<script setup lang="ts">
import { DocxEditor } from '@docx-editor.dev/vue';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/vue';
import type { FontConfigurationFragment } from '@docx-editor.dev/vue';
import '@docx-editor.dev/core/styles/editor.css';
defineProps<{ bytes: Uint8Array; fonts: FontConfigurationFragment }>();
const modules = [reviewModule()];
</script>
<template>
<div class="docx-editor contract-workspace">
<DocxEditor.Root
:document="bytes"
:fonts="fonts"
:modules="modules"
author="Demo reviewer"
mode="suggesting"
:revision-markup="{
insertions: { mark: 'underline', color: 'darkGreen', background: 'lightGreen' },
deletions: { mark: 'strikethrough', color: 'darkRed', background: 'pink' },
}"
review-display-mode="all-markup"
>
<DocxEditor.ColorByChangeType />
<DocxEditor.Viewport :style="{ height: '520px' }">
<DocxEditor.Content />
<DocxEditor.HyperLink />
<DocxEditor.ContextMenu />
<DocxEditorReview />
</DocxEditor.Viewport>
</DocxEditor.Root>
</div>
</template>Set author colors and avatars
The author name must match the document value. Author styling does not prove identity. Use a trusted application URL for each avatar.
<DocxEditor.AuthorStyle
author="Legal reviewer"
color="var(--doc-review-author-0)"
background="transparent"
avatarUrl="/avatars/legal-reviewer.png"
/><DocxEditor.AuthorStyle
author="Legal reviewer"
color="var(--doc-review-author-0)"
background="transparent"
avatar-url="/avatars/legal-reviewer.png"
/>Mount each declaration under the editor root. Override the author color palette with your application color tokens. Use --doc-revision-insertion-decoration-style to change insertion underlines. Use revisionMarkup.changedLines for change bar placement.
Named markup colors use --doc-revision-color-* tokens. revisionMarkup does not accept arbitrary CSS color strings. PDF output uses named print colors and the default author color palette. Application CSS and revisionStyles do not change PDF colors.
Style tracked change balloons
The review module can open each tracked change in a tracked change balloon at its text. Start with the built-in tracked change balloon. Build your own tracked change balloon only when your design needs a different component.
Use the built-in tracked change balloons
Set revisionsIn to 'balloons'. The review pane then lists comments only, and DocxEditorReview opens a tracked change balloon at the text of each tracked change. The tracked change balloon has the author, the date, the changed words, replies, a reply field, and Accept and Reject.
import { DocxEditor } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';
const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];
<DocxEditor.Root document={bytes} modules={modules} author="Jess Lin">
<DocxEditor.Viewport style={{ height: 520 }}>
<DocxEditor.Content />
<DocxEditorReview />
</DocxEditor.Viewport>
</DocxEditor.Root>;<script setup lang="ts">
import { DocxEditor } from '@docx-editor.dev/vue';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/vue';
defineProps<{ bytes: Uint8Array }>();
const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];
</script>
<template>
<DocxEditor.Root :document="bytes" :modules="modules" author="Jess Lin">
<DocxEditor.Viewport :style="{ height: '520px' }">
<DocxEditor.Content />
<DocxEditorReview />
</DocxEditor.Viewport>
</DocxEditor.Root>
</template>To switch between the pane and tracked change balloons at runtime, and to combine tracked change balloons with other display settings, see Tracked changes.
To add a class to the built-in tracked change balloon, pass className to DocxEditorReview.Balloon. The part takes ReviewBalloonProps: className, and hidden to remove the built-in tracked change balloon.
<DocxEditorReview>
<DocxEditorReview.Balloon className="contract-change-balloon" />
</DocxEditorReview><DocxEditorReview>
<DocxEditorReview.Balloon class-name="contract-change-balloon" />
</DocxEditorReview>Style the built-in tracked change balloon
Balloon.className styles the tracked change balloon root. Target its card to change the visible box. The following styles work with both framework examples.
.docx-editor.contract-workspace .contract-change-balloon .docx-review__card {
border-radius: 12px;
border-inline-start: 3px solid var(--doc-review-author-current);
background: var(--doc-surface);
box-shadow: 0 8px 24px var(--doc-border);
}
.docx-editor.contract-workspace .contract-change-balloon .docx-review__reply-box {
border-radius: 8px;
}Use border-radius: 3px for a square box. Keep the built-in actions and reply field when you change its appearance. The tracked change balloon pairs replacement records into one decision. For author colors and text marks, see Tracked changes.
Build a custom tracked change balloon
The live example styles the built-in tracked change balloon. The following reference shows how to replace it with your own component.
Keep revisionsIn: 'balloons' and pass hidden to DocxEditorReview.Balloon. The review pane then lists comments only, the built-in tracked change balloon stays closed, and your tracked change balloon is the route to each tracked change. This also removes built-in tracked change balloons for formatting and structural changes. Your component must handle every revision kind.
Read the item and its position from the editor, not from the painted pages:
- On a click, call
editor.getReviewItemsAt(event.clientX, event.clientY, { pairReplacements: true }). It returns the review items under the point, innermost first. Open the first revision. Do not callsetActiveon a click, because it moves the caret to the start of the change. - To place the tracked change balloon, call
editor.getReviewItemRects(key). It returns the painted bands of the item in client coordinates. Anchor the tracked change balloon to the first band. If the array is empty, the item has no painted text, so hide the tracked change balloon. Read the bands again after a scroll, a resize, eachchangeevent, a zoom change, and when the review pane opens or closes. - To follow Next Change and Previous Change, listen for
reviewItemRevealwithuseEditorEvent. Navigation steps through the unpaired queue, so for a replacement the event'skeynames one half. The event also carriespairKey, the key of the pair, so openpairKey ?? key. If your query excludes replacements, withexcludeRevisionKinds: ['replace'], openkey, becausepairKeyis not in that list. You can readactivatedKeyfromuseReview()instead. Do not open the tracked change balloon foractiveKey. It follows the caret, so the tracked change balloon opens each time the caret lands in a change. - Pass the same query,
{ pairReplacements: true }, touseReview()and to the hit test, so that a hit names an item in your list. A replacement is then one item withrevisionKind: 'replace'. - Check the Boolean that
acceptandrejectreturn. Handle Escape, dismissal, read-only items, and cleanup.
React and Vue custom component reference
Pass your DOCX bytes and loaded fonts to this component. The preview uses a fictional contract.
import { useRef } from 'react';
import { ContractBalloons } from './ContractBalloons';
import { DocxEditor, type DocxEditorRootProps } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';
import '@docx-editor.dev/core/styles/editor.css';
const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];
export function ContractEditor({
bytes,
fonts,
}: {
bytes: Uint8Array;
fonts: DocxEditorRootProps['fonts'];
}) {
const container = useRef<HTMLDivElement>(null);
return (
<div className="docx-editor contract-workspace">
<DocxEditor.Root
document={bytes}
fonts={fonts}
modules={modules}
author="Demo reviewer"
mode="suggesting"
reviewDisplayMode="all-markup"
>
<DocxEditor.Toolbar />
<div ref={container} className="contract-stage">
<DocxEditor.Viewport style={{ height: 520 }}>
<DocxEditor.Content />
<DocxEditor.HyperLink />
<DocxEditor.ContextMenu />
<DocxEditorReview>
<DocxEditorReview.Balloon hidden />
</DocxEditorReview>
</DocxEditor.Viewport>
<ContractBalloons container={container} rounded />
</div>
</DocxEditor.Root>
</div>
);
}<script setup lang="ts">
import { ref } from 'vue';
import ContractBalloons from './ContractBalloons.vue';
import { DocxEditor } from '@docx-editor.dev/vue';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/vue';
import type { FontConfigurationFragment } from '@docx-editor.dev/vue';
import '@docx-editor.dev/core/styles/editor.css';
defineProps<{ bytes: Uint8Array; fonts: FontConfigurationFragment }>();
const container = ref<HTMLElement | null>(null);
const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];
</script>
<template>
<div class="docx-editor contract-workspace">
<DocxEditor.Root
:document="bytes"
:fonts="fonts"
:modules="modules"
author="Demo reviewer"
mode="suggesting"
review-display-mode="all-markup"
>
<DocxEditor.Toolbar />
<div ref="container" class="contract-stage">
<DocxEditor.Viewport :style="{ height: '520px' }">
<DocxEditor.Content />
<DocxEditor.HyperLink />
<DocxEditor.ContextMenu />
<DocxEditorReview>
<DocxEditorReview.Balloon hidden />
</DocxEditorReview>
</DocxEditor.Viewport>
<ContractBalloons :container="container" />
</div>
</DocxEditor.Root>
</div>
</template>Create the custom component
Create ContractBalloons.tsx or ContractBalloons.vue with these examples. Both use the same placement helper. Set authorAccent in React or author-accent in Vue to tint the tracked change balloon by author.
'use client';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useDocxEditor, useEditorEvent, useReviewAuthors } from '@docx-editor.dev/react';
import { useReview } from '@docx-editor.dev/pro/react';
import { revisionLabel, watchBalloonAnchor, type BalloonPosition } from './watch-balloon-anchor';
/** One key for a replacement, in the hit test and in the review queue. */
const query = { pairReplacements: true };
/** Custom tracked change balloons. */
export function ContractBalloons({
container,
rounded,
authorAccent = false,
}: {
container: RefObject<HTMLDivElement | null>;
rounded: boolean;
authorAccent?: boolean;
}) {
const editor = useDocxEditor();
const { items, accept, reject } = useReview(query);
const authors = useReviewAuthors();
const panel = useRef<HTMLDivElement>(null);
const [openKey, setOpenKey] = useState<string | null>(null);
const [position, setPosition] = useState<BalloonPosition | null>(null);
const [failure, setFailure] = useState('');
const item = items.find((entry) => entry.key === openKey && entry.kind === 'revision');
const itemKey = item?.key;
// Next Change and Previous Change ask the host to open an item.
// Navigation names one half of a replacement; pairKey names the pair in `items`.
useEditorEvent('reviewItemReveal', ({ key, pairKey }) => {
setFailure('');
setOpenKey(pairKey ?? key);
});
useEffect(() => {
const host = container.current;
if (!host || !editor) return;
const click = (event: MouseEvent) => {
if (event.defaultPrevented || !host.ownerDocument.getSelection()?.isCollapsed) return;
if (event.target instanceof Node && panel.current?.contains(event.target)) return;
// Read the item under the pointer. Do not activate it: that moves the caret.
const hit = editor
.getReviewItemsAt(event.clientX, event.clientY, query)
.find((entry) => entry.placement.kind === 'revision');
setFailure('');
setOpenKey(hit?.placement.key ?? null);
};
const keydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenKey(null);
};
host.addEventListener('click', click);
host.addEventListener('keydown', keydown);
return () => {
host.removeEventListener('click', click);
host.removeEventListener('keydown', keydown);
};
}, [container, editor]);
useEffect(() => {
const host = container.current;
if (!host || !editor || !itemKey) {
setPosition(null);
return;
}
return watchBalloonAnchor(editor, host, itemKey, setPosition);
}, [container, editor, itemKey]);
if (!item || item.kind !== 'revision' || !position) return null;
const authorColor =
authors.find((author) => author.author === item.author)?.color ?? 'var(--doc-primary)';
return (
<div
ref={panel}
className="contract-balloon"
style={{
...position,
borderRadius: rounded ? 16 : 4,
borderInlineStart: authorAccent ? `3px solid ${authorColor}` : undefined,
background: authorAccent
? `color-mix(in srgb, ${authorColor} 8%, var(--doc-surface))`
: undefined,
}}
data-review-author={item.author}
role="group"
aria-label="Tracked change balloon"
onMouseDown={(event) => event.preventDefault()}
>
<div className="contract-balloonHeading">
<span className="contract-initials">{item.initials}</span>
<strong>{item.author}</strong>
<button
type="button"
aria-label="Close tracked change balloon"
onClick={() => setOpenKey(null)}
>
×
</button>
</div>
<p>{revisionLabel(item.revisionKind)}</p>
{item.replacedText && (
<p className="contract-excerpt">
<del>{item.replacedText}</del>
</p>
)}
<p className="contract-excerpt">{item.text}</p>
<div className="contract-decisions">
<button
type="button"
disabled={item.readOnly}
onClick={() => {
if (!accept(item))
setFailure('The editor refused this change. Review the document and try again.');
}}
>
Accept
</button>
<button
type="button"
disabled={item.readOnly}
onClick={() => {
if (!reject(item))
setFailure('The editor refused this change. Review the document and try again.');
}}
>
Reject
</button>
</div>
{failure && <p role="alert">{failure}</p>}
</div>
);
}<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue';
import { useDocxEditor, useEditorEvent, useReviewAuthors } from '@docx-editor.dev/vue';
import { useReview } from '@docx-editor.dev/pro/vue';
import { revisionLabel, watchBalloonAnchor } from './watch-balloon-anchor';
import type { BalloonPosition } from './watch-balloon-anchor';
/** One key for a replacement, in the hit test and in the review queue. */
const query = { pairReplacements: true };
const props = defineProps<{
container: HTMLElement | null;
authorAccent?: boolean;
}>();
const editor = useDocxEditor();
const authors = useReviewAuthors();
const { items, accept, reject } = useReview(query);
const panel = ref<HTMLElement | null>(null);
const openKey = ref<string | null>(null);
const position = ref<BalloonPosition | null>(null);
const failure = ref('');
const item = computed(() =>
items.value.find((entry) => entry.key === openKey.value && entry.kind === 'revision')
);
const authorColor = computed(
() =>
authors.value.find((author) => author.author === item.value?.author)?.color ??
'var(--doc-primary)'
);
// Next Change and Previous Change ask the host to open an item.
// Navigation names one half of a replacement; pairKey names the pair in `items`.
useEditorEvent('reviewItemReveal', ({ key, pairKey }) => {
failure.value = '';
openKey.value = pairKey ?? key;
});
watchEffect((cleanup) => {
const host = props.container;
const view = editor.value;
if (!host || !view) return;
const click = (event: MouseEvent) => {
if (event.defaultPrevented || !host.ownerDocument.getSelection()?.isCollapsed) return;
if (event.target instanceof Node && panel.value?.contains(event.target)) return;
// Read the item under the pointer. Do not activate it: that moves the caret.
const hit = view
.getReviewItemsAt(event.clientX, event.clientY, query)
.find((entry) => entry.placement.kind === 'revision');
failure.value = '';
openKey.value = hit?.placement.key ?? null;
};
const keydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') openKey.value = null;
};
host.addEventListener('click', click);
host.addEventListener('keydown', keydown);
cleanup(() => {
host.removeEventListener('click', click);
host.removeEventListener('keydown', keydown);
});
});
watchEffect((cleanup) => {
const host = props.container;
const view = editor.value;
const key = item.value?.key;
if (!host || !view || !key) {
position.value = null;
return;
}
cleanup(
watchBalloonAnchor(view, host, key, (value) => {
position.value = value;
})
);
});
function decide(kind: 'accept' | 'reject') {
const active = item.value;
if (!active || active.kind !== 'revision') return;
if (!(kind === 'accept' ? accept(active) : reject(active))) {
failure.value = 'The editor refused this change. Review the document and try again.';
}
}
</script>
<template>
<div
v-if="item?.kind === 'revision' && position"
ref="panel"
class="contract-balloon"
role="group"
aria-label="Tracked change balloon"
:data-review-author="item.author"
:style="{
left: `${position.left}px`,
top: `${position.top}px`,
borderInlineStart: authorAccent ? `3px solid ${authorColor}` : undefined,
background: authorAccent
? `color-mix(in srgb, ${authorColor} 8%, var(--doc-surface))`
: undefined,
}"
@mousedown.prevent
>
<strong>{{ item.author }}</strong>
<button type="button" aria-label="Close tracked change balloon" @click="openKey = null">
×
</button>
<p>{{ revisionLabel(item.revisionKind) }}</p>
<p v-if="item.replacedText">
<del>{{ item.replacedText }}</del>
</p>
<p>{{ item.text }}</p>
<button type="button" :disabled="item.readOnly" @click="decide('accept')">Accept</button>
<button type="button" :disabled="item.readOnly" @click="decide('reject')">Reject</button>
<p v-if="failure" role="alert">{{ failure }}</p>
</div>
</template>Place the tracked change balloon
Create watch-balloon-anchor.ts beside your tracked change balloon component. It reads getReviewItemRects(key) and positions the component below the first text band.
The helper updates after scrolling, resizing, document changes, zoom changes, and review pane transitions. It hides the component when the item has no visible band.
import type { Editor } from '@docx-editor.dev/core';
export interface BalloonPosition {
left: number;
top: number;
}
/** Places a balloon under the first painted band of one review item. */
export function watchBalloonAnchor(
editor: Editor,
host: HTMLElement,
key: string,
notify: (position: BalloonPosition | null) => void
): () => void {
let frame = 0;
function update() {
frame = 0;
const [band] = editor.getReviewItemRects(key);
const bounds = host.getBoundingClientRect();
if (!band || band.bottom < bounds.top || band.top > bounds.bottom) {
notify(null);
return;
}
const width = Math.min(280, host.clientWidth - 24);
notify({
left: Math.max(12, Math.min(band.left - bounds.left, host.clientWidth - width - 12)),
top: band.bottom - bounds.top + 8,
});
}
function schedule() {
if (!frame) frame = requestAnimationFrame(update);
}
update();
const resize = new ResizeObserver(schedule);
resize.observe(host);
host.addEventListener('scroll', schedule, true);
// The pages slide when the review pane opens or closes; read again when they stop.
host.addEventListener('transitionend', schedule);
const offChange = editor.on('change', schedule);
// Zoom and the review pane move the pages without a document change.
let { zoom, reviewPaneOpen } = editor.snapshot();
const offView = editor.on('selectionChange', (snapshot) => {
if (snapshot.zoom === zoom && snapshot.reviewPaneOpen === reviewPaneOpen) return;
({ zoom, reviewPaneOpen } = snapshot);
schedule();
});
return () => {
cancelAnimationFrame(frame);
resize.disconnect();
host.removeEventListener('scroll', schedule, true);
host.removeEventListener('transitionend', schedule);
offChange();
offView();
};
}
/** A short heading for each kind of tracked change. */
export function revisionLabel(kind: string): string {
if (kind === 'insert') return 'Inserted text';
if (kind === 'delete') return 'Deleted text';
if (kind === 'replace') return 'Replaced text';
return 'Tracked change';
}Style the tracked change balloon
Load these application rules after the editor stylesheet. Both framework examples use the same container and tracked change balloon classes.
.contract-stage {
position: relative;
min-height: 0;
}
.docx-editor .contract-balloon {
position: absolute;
z-index: 40;
width: min(280px, calc(100% - 24px));
padding: 14px;
border: 1px solid var(--doc-border);
border-radius: 16px;
background: var(--doc-surface);
color: var(--doc-text);
box-shadow: 0 12px 32px var(--doc-border);
}
.docx-editor .contract-balloon button {
margin: 4px;
padding: 5px 8px;
border: 1px solid var(--doc-border);
border-radius: 6px;
background: var(--doc-bg);
color: var(--doc-text);
}
.docx-editor .contract-balloon button:focus-visible {
outline: 2px solid var(--doc-primary);
outline-offset: 3px;
}A replacement records a deletion and an insertion. With useReview({ pairReplacements: true }), the tracked change balloon shows them as one item, and Accept and Reject decide both. For more information, see Show a replacement as one item.
API references
- Revision markup describes supported text marks and print colors.
- Review colors and styling lists review parts and author styling options.
- Comments and review cards describes comment box and reply styling.