Live · Consumer-owned review UI
Comments in context, composed by your app.
Parsed comments and page geometry become a review margin beside the document. Threads stay close to their anchors without covering the page, while the presentation remains entirely consumer-owned.
Demo source
The implementation behind this demo.
The polished preview intentionally opens the first page containing review items. Its exact component and controller are available on GitHub.
Portable starter
Use the same boundary without site-specific UI.
This plainer version uses only @silurus/ooxml/docx, ordinary HTML and CSS. It
includes page navigation and an explicit destroy() lifecycle for plain
TypeScript, React, Vue, or another host framework.
import {
DocxDocument,
resolveDocxCommentThreads,
type DocComment,
type DocxCommentHighlightRect,
type DocxTextRunInfo,
} from '@silurus/ooxml/docx';
const PAGE_WIDTH = 760;
const SVG_NS = 'http://www.w3.org/2000/svg';
interface ReviewItem {
id: string;
label: string;
text: string;
meta: string;
rects: readonly Readonly<DocxCommentHighlightRect>[];
marker: 'range' | 'point' | 'fallback';
replies?: readonly DocComment[];
}
interface ReviewController {
updatePage(pageIndex: number): Promise<void>;
destroy(): void;
}
function reviewItems(doc: DocxDocument, runs: readonly Readonly<DocxTextRunInfo>[]): ReviewItem[] {
return resolveDocxCommentThreads(
doc.comments,
doc.commentAnchorRanges(),
runs,
{ includeResolved: false },
).map((thread) => {
const kinds = thread.anchors.map(({ kind }) => kind);
const marker = kinds.includes('range') ? 'range'
: kinds.includes('point') ? 'point' : 'fallback';
return {
id: `comment-${thread.root.id}`,
label: thread.root.author || 'Unknown reviewer',
text: thread.root.text,
meta: `${thread.root.resolved ? 'Resolved thread' : 'Open thread'}${marker === 'point' ? ' · authored boundary' : marker === 'fallback' ? ' · approximate final-state position' : ''}`,
rects: thread.anchors.flatMap(({ rects }) => rects),
marker,
replies: thread.replies,
};
});
}
export async function mountReviewExample(root: HTMLElement, signal?: AbortSignal): Promise<ReviewController> {
const canvas = root.querySelector('canvas') as HTMLCanvasElement;
const highlights = root.querySelector('[data-review-highlights]') as SVGSVGElement;
const list = root.querySelector('[data-review-items]') as HTMLOListElement;
const status = root.querySelector('[data-review-status]') as HTMLElement;
const content = root.querySelector('[data-review-content]') as HTMLElement;
const empty = root.querySelector('[data-review-empty]') as HTMLElement;
const pageStatus = root.querySelector('[data-review-page-status]') as HTMLElement;
const previous = root.querySelector('[data-review-previous]') as HTMLButtonElement;
const next = root.querySelector('[data-review-next]') as HTMLButtonElement;
const events = new AbortController();
let doc: DocxDocument | undefined;
let generation = 0;
let destroyed = false;
let selectedId: string | undefined;
let previewId: string | undefined;
const paintSelection = (id?: string): void => {
root.querySelectorAll<HTMLElement>('[data-review-id]').forEach((element) => {
const selected = id !== undefined && element.dataset.reviewId === id;
if (element.classList.contains('review-example__item')) {
element.setAttribute('aria-pressed', String(selected));
} else {
element.dataset.selected = String(selected);
}
});
};
root.addEventListener('click', (event) => {
const target = (event.target as Element).closest<HTMLElement>('[data-review-id]');
if (target) {
selectedId = selectedId === target.dataset.reviewId ? undefined : target.dataset.reviewId;
paintSelection(selectedId);
}
}, { signal: events.signal });
root.addEventListener('mouseover', (event) => {
const target = (event.target as Element).closest<HTMLElement>('[data-review-id]');
if (target) { previewId = target.dataset.reviewId; paintSelection(previewId); }
}, { signal: events.signal });
root.addEventListener('mouseout', (event) => {
const target = (event.target as Element).closest<HTMLElement>('[data-review-id]');
if (target) { previewId = undefined; paintSelection(selectedId); }
}, { signal: events.signal });
root.addEventListener('focusin', (event) => {
const target = (event.target as Element).closest<HTMLElement>('[data-review-id]');
if (target) { previewId = target.dataset.reviewId; paintSelection(previewId); }
}, { signal: events.signal });
root.addEventListener('focusout', () => {
previewId = undefined;
paintSelection(selectedId);
}, { signal: events.signal });
const destroy = (): void => {
if (destroyed) return;
destroyed = true;
generation += 1;
events.abort();
doc?.destroy();
doc = undefined;
};
signal?.addEventListener('abort', destroy, { once: true });
if (signal?.aborted) destroy();
const updatePage = async (requestedPage: number): Promise<void> => {
if (!doc || destroyed) return;
const pageIndex = Math.max(0, Math.min(requestedPage, doc.pageCount - 1));
const request = ++generation;
const stage = document.createElement('canvas');
const runs: DocxTextRunInfo[] = [];
await doc.renderPage(stage, pageIndex, { width: PAGE_WIDTH, onTextRun: (run) => runs.push(run) });
if (destroyed || request !== generation) return;
canvas.width = stage.width;
canvas.height = stage.height;
canvas.getContext('2d')?.drawImage(stage, 0, 0);
canvas.style.width = '100%';
canvas.style.height = 'auto';
const page = doc.pageSize(pageIndex);
const pageHeight = PAGE_WIDTH * page.heightPt / page.widthPt;
highlights.setAttribute('viewBox', `0 0 ${PAGE_WIDTH} ${pageHeight}`);
highlights.replaceChildren();
list.replaceChildren();
const items = reviewItems(doc, runs);
for (const item of items) {
const row = document.createElement('li');
row.id = `review-row-${item.id}`;
const button = document.createElement('button');
button.type = 'button';
button.className = 'review-example__item';
button.dataset.reviewId = item.id;
button.setAttribute('aria-pressed', 'false');
button.setAttribute('aria-label', `${item.label}. Page ${pageIndex + 1}. ${item.text}. Anchored to the highlighted document text.`);
const label = document.createElement('strong');
const body = document.createElement('span');
const meta = document.createElement('span');
label.textContent = item.label;
body.textContent = item.text;
meta.className = 'review-example__meta';
meta.textContent = `Page ${pageIndex + 1} · ${item.meta}`;
button.append(label, body, meta);
if (item.replies?.length) {
const replies = document.createElement('span');
replies.className = 'review-example__replies';
for (const reply of item.replies) {
const paragraph = document.createElement('span');
paragraph.textContent = `${reply.author || 'Unknown reviewer'}: ${reply.text}`;
replies.append(paragraph);
}
button.append(replies);
}
row.append(button);
list.append(row);
const bands = item.marker === 'range' ? item.rects : item.rects.slice(0, 1);
const anchorIds: string[] = [];
for (const [index, band] of bands.entries()) {
const rect = document.createElementNS(SVG_NS, 'rect');
rect.id = `review-anchor-${item.id}-${index}`;
anchorIds.push(rect.id);
rect.classList.add('review-example__anchor');
if (item.marker !== 'range') rect.classList.add('review-example__anchor--point');
if (item.marker === 'fallback') rect.classList.add('review-example__anchor--fallback');
rect.dataset.reviewId = item.id;
rect.setAttribute('aria-hidden', 'true');
rect.setAttribute('focusable', 'false');
rect.setAttribute('x', String(band.x));
rect.setAttribute('y', String(band.y));
rect.setAttribute('width', String(item.marker === 'range' ? band.width : 3));
rect.setAttribute('height', String(band.height));
if (band.transform) {
rect.style.transform = band.transform;
rect.style.transformOrigin = `${band.x}px ${band.y}px`;
}
highlights.append(rect);
}
if (anchorIds.length > 0) button.setAttribute('aria-controls', anchorIds.join(' '));
}
empty.hidden = items.length > 0;
previous.disabled = pageIndex === 0;
next.disabled = pageIndex === doc.pageCount - 1;
previous.dataset.page = String(pageIndex - 1);
next.dataset.page = String(pageIndex + 1);
pageStatus.textContent = `Page ${pageIndex + 1} of ${doc.pageCount}`;
status.textContent = `Page ${pageIndex + 1} loaded with ${items.length} review item${items.length === 1 ? '' : 's'}.`;
status.classList.add('review-example__status--ready');
content.hidden = false;
selectedId = undefined;
previewId = undefined;
paintSelection(undefined);
};
const navigate = (event: Event): void => {
const page = Number((event.currentTarget as HTMLButtonElement).dataset.page);
if (Number.isInteger(page)) {
void updatePage(page).catch((error: unknown) => {
status.classList.remove('review-example__status--ready');
status.textContent = error instanceof Error ? error.message : 'Unable to render this page.';
});
}
};
previous.addEventListener('click', navigate, { signal: events.signal });
next.addEventListener('click', navigate, { signal: events.signal });
try {
const source = root.dataset.src;
if (!source) throw new Error('Set data-src to a same-origin or CORS-enabled DOCX URL.');
doc = await DocxDocument.load(source);
if (destroyed) { doc.destroy(); return { updatePage, destroy }; }
if (doc.pageCount === 0) throw new Error('The DOCX has no renderable pages.');
await updatePage(Number(root.dataset.page ?? 0));
} catch (error) {
doc?.destroy();
doc = undefined;
status.textContent = error instanceof Error ? error.message : 'Unable to load review data.';
throw error;
}
return {
updatePage,
destroy,
};
}
const lifetime = new AbortController();
const mounted = await Promise.all(
[...document.querySelectorAll<HTMLElement>('[data-review-example]')].map(async (root) => {
try { return await mountReviewExample(root, lifetime.signal); }
catch { return undefined; } // mountReviewExample has already announced the error.
}),
);
const controllers = mounted.filter((item): item is ReviewController => item !== undefined);
// This site uses Astro navigation. In a framework component, call controller.destroy()
// from that component's unmount/effect cleanup instead.
document.addEventListener('astro:before-swap', () => {
lifetime.abort();
controllers.forEach((controller) => controller.destroy());
}, { once: true }); <div class="review-example" data-review-example data-src="/samples/sample-1.docx" data-page="1">
<p class="review-example__status" data-review-status role="status">Loading review data…</p>
<div class="review-example__content" data-review-content hidden>
<div class="review-example__toolbar" aria-label="Document page">
<button type="button" data-review-previous>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 6l-6 6l6 6" /></svg>
<span>Previous</span>
</button>
<span data-review-page-status></span>
<button type="button" data-review-next>
<span>Next</span>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6l6 6l-6 6" /></svg>
</button>
</div>
<div class="review-example__layout">
<div class="review-example__page">
<canvas data-review-canvas aria-label="Rendered DOCX page"></canvas>
<svg data-review-highlights role="group" aria-label="Review anchors on this page"></svg>
</div>
<aside class="review-example__margin" aria-label="Review transcript">
<p class="review-example__empty" data-review-empty hidden>No review items on this page.</p>
<ol data-review-items></ol>
</aside>
</div>
</div>
</div> .review-example {
--review-border: var(--border, #d8dee8);
--review-workspace: var(--preview-bottom, #eef1f5);
--review-panel: var(--panel, #fff);
--review-text: var(--text, #172333);
--review-text-dim: var(--text-dim, #516071);
--review-accent: var(--accent, #2563eb);
container-type: inline-size;
padding: 16px;
border: 1px solid var(--review-border);
border-radius: 12px;
background: var(--review-workspace);
}
.review-example__status,
.review-example__empty { color: var(--review-text-dim); text-align: center; }
.review-example__status { margin: 0; padding: 64px 16px; }
.review-example__status--ready {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.review-example__toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
color: var(--review-text-dim);
font-size: 12px;
}
.review-example__toolbar button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
inline-size: 104px;
min-block-size: 34px;
border: 1px solid var(--review-border);
border-radius: 9px;
padding: 6px 12px;
background: var(--review-panel);
color: var(--review-text);
box-shadow: 0 1px 2px rgb(15 23 42 / .08);
font: 600 12px/1 system-ui, sans-serif;
cursor: pointer;
transition: border-color .16s ease, background .16s ease, color .16s ease, box-shadow .16s ease;
}
.review-example__toolbar button svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.review-example__toolbar button:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--review-accent) 48%, var(--review-border));
background: color-mix(in srgb, var(--review-accent) 7%, var(--review-panel));
color: var(--review-accent);
box-shadow: 0 3px 8px rgb(15 23 42 / .1);
}
.review-example__toolbar button:focus-visible {
outline: 2px solid var(--review-accent);
outline-offset: 2px;
}
.review-example__toolbar button:disabled { opacity: .42; cursor: default; box-shadow: none; }
.review-example__layout {
display: grid;
grid-template-columns: minmax(360px, 760px) minmax(190px, 240px);
gap: 14px;
max-width: 1014px;
margin: 0 auto;
align-items: start;
}
.review-example__page { position: relative; min-width: 0; }
.review-example__page canvas { display: block; width: 100%; height: auto; background: #fff; }
[data-review-highlights] { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.review-example__anchor {
fill: rgba(43, 124, 255, .16);
stroke: none;
transform-origin: top left;
pointer-events: auto;
}
.review-example__anchor--point {
fill: #2b7cff;
stroke: rgba(255, 255, 255, .8);
stroke-width: 2px;
}
.review-example__anchor--fallback { stroke-dasharray: 2 2; }
.review-example__anchor:is(:hover, [data-selected='true']) {
fill: rgba(43, 124, 255, .36);
stroke: #2b7cff;
stroke-width: 2px;
outline: none;
}
.review-example__margin ol { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.review-example__item {
display: grid;
gap: 5px;
width: 100%;
border: 0;
border-radius: 8px;
padding: 10px;
background: color-mix(in srgb, var(--review-panel) 96%, var(--review-text) 4%);
color: var(--review-text);
text-align: left;
}
.review-example__item:is(:hover, :focus-visible, [aria-pressed='true']) {
background: color-mix(in srgb, #2b7cff 12%, var(--review-panel));
outline: 2px solid #2b7cff;
}
.review-example__item strong { font-size: 12px; }
.review-example__item span,
.review-example__item del { color: var(--review-text-dim); font-size: 11px; line-height: 1.5; }
.review-example__meta { font-style: italic; }
.review-example__replies { margin: 7px 0 0 12px; padding-left: 12px; border-left: 1px solid var(--review-border); }
.review-example__replies > span { display: block; margin: 4px 0; color: var(--review-text-dim); font-size: 11px; }
@container (max-width: 720px) {
.review-example__layout { grid-template-columns: 1fr; }
}