Document viewers
Order documents arrive as PDFs, images, spreadsheets, CSVs, and emails. This is the full review
document column β one viewer per format plus a DocViewer that routes by file type (the
design-system version of the appβs ReviewDocViewer). All use the same normalized (0..1) clickable
highlight overlays so extracted fields track the document at any render width.
PDFViewer
Section titled βPDFViewerβRenders a PDF page-by-page (fit-width) with clickable highlight overlays for extracted fields, built on pdf.js. The orange boxes are extraction highlights; click one to focus its field.
import PDFViewer, { type PDFHighlight } from '@repo/ui/PDFViewer';
const highlights: PDFHighlight[] = [ { page: 1, x: 0.08, y: 0.08, w: 0.34, h: 0.05, label: 'PO number' },];
<PDFViewer src="/orders/123.pdf" highlights={highlights} onHighlightClick={(h) => focusField(h.label)} onPageChange={setPage} />- SSR-safe: pdf.js is dynamically imported client-side; the worker loads from a Vite
?urlasset. Render as a client island. - Page nav (
βΉ/βΊ) is keyboard-operable with a visible focus ring; highlights are buttons witharia-label. - Coords are normalized 0..1 relative to the page (resolution-independent). The appβs extraction
stores
page_rectin PDF points β convert withx / pageWidth,y / pageHeight.
Image, spreadsheet & email
Section titled βImage, spreadsheet & emailβ| 3 | SKU | Description | Qty | Unit |
|---|---|---|---|---|
| 4 | BV-1001 | Cola syrup, 5gal | 12 | $48.00 |
| 5 | BV-1002 | Sparkling water, case | 40 | $18.10 |
| 6 | BV-1003 | Citrus base, 2gal | 6 | $31.50 |
ImageViewer
Section titled βImageViewerβimport ImageViewer from '@repo/ui/ImageViewer';
<ImageViewer src={url} highlights={[{ x: 0.04, y: 0.2, w: 0.26, h: 0.06, label: 'PO number' }]} onHighlightClick={(h) => focusField(h.label)} />Image counterpart to PDFViewer: normalized (0..1) clickable highlight overlays, dark review
surface by default (surface="light" for the workspace). Overlays use % positioning, so they
track the image at any size.
SpreadsheetView
Section titled βSpreadsheetViewβimport SpreadsheetView, { parseCSV, type Sheet } from '@repo/ui/SpreadsheetView';
const sheets: Sheet[] = [ { name: 'West DC', detectedHeaderRow: 2, // metadata rows 0β1 sit above the table header badge: { kind: 'submitted', title: 'Submitted to ERP' }, rows: parsedRows, },];// Highlight the source row by matching an extracted item (fuzzy: SKU β name)β¦<SpreadsheetView sheets={sheets} highlightItem={{ itemIds: ['BV-1002'], itemName: 'β¦' }} />// β¦or by explicit index:<SpreadsheetView sheets={sheets} highlightRows={[3]} />The full multi-tab PO shape (parity with the appβs SpreadsheetFullView):
detectedHeaderRowβ metadata rows above the real header collapse into a disclosure (POs put title/ship-to/terms above the table); the header renders as proper<th>columns.- Multi-sheet tabs with status badges (
submitted β / pending β³ / failed β / info β). - Excel row-number gutter (real numbers preserved), empty row/column filtering, truncation footer.
- Fuzzy itemβrow highlight via
highlightItemβfindMatchingRowIndexis exported (exact β leading-zero-normalized β containment β name). Clicking a matched row firesonRowClick.
Pass parsed sheets β the app gets these from its preview API; parseCSV handles raw CSV. Excel
binary parsing stays consumer/server-side; the primitive renders rows.
EmailPreview
Section titled βEmailPreviewβimport { EmailPreview, type EmailHighlight } from '@repo/ui/EmailPreview';
const highlights: EmailHighlight[] = [ { id: 'po', label: 'PO number', start: 28, end: 36 }, // char span into textBody];
<EmailPreview email={{ from, fromName, to, cc, subject, date, textBody, htmlBody, attachments }} highlights={highlights} activeHighlightId="po" onHighlightClick={(h) => focusField(h.id)} onAttachmentClick={(a) => openAttachment(a)}/>The email branch of the review doc viewer: header (from/to/cc/subject/date), body β HTML in a
sandboxed iframe or plain text β and clickable attachment chips. Many POs arrive as a forwarded
.eml/.msg with the real document attached, so the chips hand off to the matching viewer.
Extraction highlights use char spans ({ start, end }) into the body text β the same
text_span model the appβs extraction emits, since email text reflows and pixel rects donβt apply.
The active span gets a stronger ring and scrolls into view.
Both bodies are highlighted. Text body: every span is wrapped inline. HTML body: rendered in
an iframe with sandbox="allow-same-origin" (and never allow-scripts, so the emailβs own JS
canβt run) plus a strict CSP that blocks scripts and remote loads; because nothing executes inside,
the parent safely reaches into the iframe DOM and wraps the active span via the Range API. Span
offsets resolve against textBody, or the rendered innerText if no textBody is given.
Why not a canvas? Rasterizing HTML still means rendering the untrusted markup in a DOM first (no safety gain), and a bitmap has no text geometry to position highlights on. Safety comes from the script-less sandbox + CSP, not from the container.
DocViewer
Section titled βDocViewerβimport DocViewer from '@repo/ui/DocViewer';
<DocViewer fileType={doc.type} filename={doc.name} src={signedUrl} sheets={parsedSheets} text={textBody} email={parsedEmail} highlights={highlights} />Routes by fileType (mirrors ReviewDocViewer): pdf β PDFViewer, image types β ImageViewer,
xlsx/xls/csv β SpreadsheetView, eml/msg/email β EmailPreview, txt/text β inline text,
anything else β a download fallback. One component for the whole review document column.