Build a custom review panel
Render open tracked changes and let reviewers navigate, accept, or reject each one.
Configure built-in tracked changes when the standard review controls fit your workflow. Build a custom panel when your application needs to own the review queue, layout, or status messages.
This guide replaces only the review sidebar. Your application renders the queue and actions. SuperDoc keeps the queue synchronized with the marks in the DOCX.
Try the review workflow
The example keeps SuperDoc's toolbar and loads three changes across three short pages.
- Use Previous and Next to move through the review queue.
- Choose Show in document on any row to return to that change.
- Accept or reject one change. Its row disappears and the open-change count decreases.
The panel follows the same loop as the comments example: observe state, render application markup, run an action, and report its result.
Build the same panel
Start from Build your first custom control. Download the
custom review fixture to your app's public directory as
contract.docx.
Vanilla needs toolbar, Editor, and panel mounts. If you use Vanilla, replace index.html with:
<div id="toolbar"></div>
<main class="review-layout">
<div id="editor" style="height: 70vh"></div>
<aside aria-labelledby="review-heading">
<h2 id="review-heading">Review changes</h2>
<p id="change-count">Opening document...</p>
<nav aria-label="Tracked change navigation">
<button id="previous-change" type="button">Previous</button>
<button id="next-change" type="button">Next</button>
</nav>
<ul id="change-list"></ul>
<p id="review-status" role="status">Choose a change to review it.</p>
</aside>
</main>
<script type="module" src="/src/main.ts"></script>
Connect the panel
Replace the setup guide's Editor code:
import { SuperDoc } from 'superdoc';
import type { UIConfig } from 'superdoc';
import type { CommandExecutionResult, TrackChangesItem, TrackChangesSlice } from 'superdoc/ui';
import 'superdoc/style.css';
function getElement<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing review panel element: ${selector}`);
return element;
}
const toolbar = getElement<HTMLDivElement>('#toolbar');
const changeCount = getElement<HTMLParagraphElement>('#change-count');
const changeList = getElement<HTMLUListElement>('#change-list');
const previousChange = getElement<HTMLButtonElement>('#previous-change');
const nextChange = getElement<HTMLButtonElement>('#next-change');
const reviewStatus = getElement<HTMLParagraphElement>('#review-status');
const editorUi = {
comments: false,
toolbar: { container: toolbar, responsiveTo: 'container' },
} satisfies UIConfig;
type Decision = 'accept' | 'reject';
let pendingDecision: { key: string; decision: Decision } | null = null;
// The occurrence this panel focused. `activeId` alone cannot tell a body row
// from a same-id footnote or header row, so the panel remembers which one it
// asked for and only trusts it while the controller still reports that id.
let activeRow: { id: string; key: string } | null = null;
let stopTrackChanges: (() => void) | null = null;
// The last snapshot from `observe()`. It is the complete review directory;
// the passive snapshot is bounded to the painted page window.
let lastChanges: TrackChangesSlice | null = null;
function decisionFailure(result: CommandExecutionResult): string | null {
if (result === false) return 'The review decision is unavailable.';
if (result === true || result.success) return null;
return result.failure.message;
}
/** A row's exact occurrence: the id plus its story when the change is outside the body. */
function decisionTarget(change: TrackChangesItem): { id: string; story?: unknown } {
const story = change.address?.story;
return story ? { id: change.id, story } : { id: change.id };
}
/** Stable per-occurrence key. The same id can appear in the body and in a footnote or header. */
function rowKey(change: TrackChangesItem): string {
return `${change.id}:${JSON.stringify(change.address?.story ?? null)}`;
}
/** Whether this row is the active occurrence, not merely a row sharing the active id. */
function isActiveRow(change: TrackChangesItem, activeId: string | null): boolean {
if (change.id !== activeId) return false;
return activeRow === null || activeRow.id !== activeId || activeRow.key === rowKey(change);
}
const superdoc = new SuperDoc({
selector: '#editor',
document: '/contract.docx',
documentMode: 'suggesting',
ui: editorUi,
user: { name: 'Alex Rivera', email: '[email protected]' },
onReady: ({ superdoc: readySuperDoc }) => {
stopTrackChanges?.();
const { ui } = readySuperDoc;
const showChange = async (change: TrackChangesItem) => {
if (pendingDecision) return;
// The row's { id, story } pins the clicked occurrence for both focus and
// reveal when the same id appears in the body and a footnote or header.
const target = decisionTarget(change);
if (!ui.trackChanges.setActive(target)) {
reviewStatus.textContent = 'The tracked change is no longer available.';
return;
}
activeRow = { id: change.id, key: rowKey(change) };
if (lastChanges) render(lastChanges);
const result = await ui.trackChanges.scrollTo(target);
reviewStatus.textContent = result.success
? 'Showing the change in the document.'
: (result.reason ?? 'The tracked change could not be shown.');
};
const navigate = async (direction: 'previous' | 'next') => {
if (pendingDecision) return;
// Navigation picks the occurrence, so the panel stops asserting its own.
activeRow = null;
const result =
direction === 'previous' ? await ui.trackChanges.navigatePrevious() : await ui.trackChanges.navigateNext();
reviewStatus.textContent = result.success
? `Showing the ${direction} change.`
: 'No tracked change could be shown.';
};
const decideChange = async (decision: Decision, change: TrackChangesItem) => {
if (pendingDecision) return;
pendingDecision = { key: rowKey(change), decision };
reviewStatus.textContent = decision === 'accept' ? 'Accepting change...' : 'Rejecting change...';
if (lastChanges) render(lastChanges);
// The async form resolves once the document operation settles, so a
// late failure still clears the pending state and reaches the reader.
const target = decisionTarget(change);
const result =
decision === 'accept' ? await ui.trackChanges.acceptAsync(target) : await ui.trackChanges.rejectAsync(target);
pendingDecision = null;
reviewStatus.textContent =
decisionFailure(result) ?? (decision === 'accept' ? 'Change accepted.' : 'Change rejected.');
if (lastChanges) render(lastChanges);
};
const render = (changes: TrackChangesSlice) => {
lastChanges = changes;
changeCount.textContent = changes.status === 'pending' ? 'Loading changes...' : `${changes.total} open changes`;
changeList.replaceChildren();
previousChange.disabled = changes.status === 'pending' || changes.total === 0 || pendingDecision !== null;
nextChange.disabled = previousChange.disabled;
for (const change of changes.items) {
const row = document.createElement('li');
const summary = document.createElement('span');
const show = document.createElement('button');
const accept = document.createElement('button');
const reject = document.createElement('button');
const detail =
change.excerpt ?? change.insertedText ?? change.deletedText ?? change.formattingDeltaSummary ?? change.type;
summary.textContent = `${detail}${change.author ? ` by ${change.author}` : ''}`;
const active = isActiveRow(change, changes.activeId);
const pending = pendingDecision?.key === rowKey(change) ? pendingDecision.decision : null;
if (active) row.setAttribute('aria-current', 'true');
show.type = 'button';
show.textContent = active ? 'Showing' : 'Show in document';
show.disabled = pendingDecision !== null;
show.addEventListener('click', () => void showChange(change));
accept.type = 'button';
accept.textContent = pending === 'accept' ? 'Accepting...' : 'Accept';
accept.disabled = pendingDecision !== null;
accept.addEventListener('click', () => void decideChange('accept', change));
reject.type = 'button';
reject.textContent = pending === 'reject' ? 'Rejecting...' : 'Reject';
reject.disabled = pendingDecision !== null;
reject.addEventListener('click', () => void decideChange('reject', change));
row.append(summary, show, accept, reject);
changeList.append(row);
}
};
stopTrackChanges = ui.trackChanges.observe(render);
previousChange.addEventListener('click', () => void navigate('previous'));
nextChange.addEventListener('click', () => void navigate('next'));
},
onContentError: ({ error }) => {
reviewStatus.textContent = 'The document could not be opened.';
console.error(error);
},
onException: ({ error }) => {
reviewStatus.textContent = 'The document could not be opened.';
console.error(error);
},
});
window.addEventListener('beforeunload', () => {
stopTrackChanges?.();
superdoc.destroy();
});
Both versions use one domain handle:
- Set
ui.commentstofalseto remove SuperDoc's comments and review sidebar without removing review data from the DOCX. - Use
observe()oruseSuperDocTrackChanges()to keep the list and active change current. - Run
setActive()withscrollTo(), ornavigatePrevious()andnavigateNext(), to move between document marks. - Run
acceptAsync()orrejectAsync()with the row's{ id, story }and await the settled result. Keep the panel pending until it resolves, then report success or the failure message.
Keep decisions synchronized
A decision can still fail after it is routed because the document is read-only, the change no longer exists, or the
Editor's interaction policy blocks it. Awaiting the async form is what lets the panel clear its pending state and show
that failure. The sync accept() and reject() return the routed result before the operation settles, so a panel that
waits for the row to disappear can lock forever. See Review tracked changes for modes and
interaction policy.
Pass the row's story with its id. The same tracked-change id can appear in the body and in a footnote or header, and
the story is what pins the decision to the occurrence the reviewer clicked.
Accepting or rejecting changes updates the open document. It does not save the DOCX. Follow Load and save documents when the review result must persist.
Verify the panel
Run the project. Move to a change on another page, then accept or reject it. The document should move to the selected mark. A successful decision should remove one row and decrease the count.
Use Document API tracked changes when application code reviews known change IDs without driving an Editor panel.
Return to the Custom UI overview to choose another workflow.