Files
Voiced/src/components/PdfPreview.tsx
T
xavierk cedf4839bd Replace the HTML preview with a worker-rendered PDF preview
Rendering moves into a module Web Worker behind a latest-wins RenderClient
with a watchdog and a handshake-based main-thread fallback. The preview
shows the real PDF through pdf.js at 2x with zoom, page indicator, audit
issues popover and a focus modal. A4/Letter and margin controls are frozen
into each invoice at issue and can be saved as the default (migration M3).
react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB
to 0.5 MB. The HTML twin and its styles are removed.
2026-10-04 05:47:19 +05:30

342 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { Button, ComposedModal, ContentSwitcher, InlineLoading, InlineNotification, ModalBody, ModalHeader, Popover, PopoverContent, Switch, Tag } from "@carbon/react";
import { Maximize, Warning } from "@carbon/icons-react";
import { cancelPageRender, closePdf, isRenderCancelled, openPdf, renderPageToCanvas, type PDFDocumentProxy } from "../lib/pdfjs";
import type { AuditIssue } from "../pdf/engine/audit";
export interface PdfPreviewProps {
bytes: Uint8Array | null;
pages: number;
issues: AuditIssue[];
rendering: boolean;
/** "main": rendering runs on the main thread, so the preview only updates when typing pauses. */
mode: "worker" | "main";
error: string | null;
/** Adds a "Focus preview" button (the modal itself is built in). */
onFocus?: () => void;
}
type Zoom = "fit" | "100" | "150";
const ZOOMS: { id: Zoom; label: string }[] = [
{ id: "fit", label: "Fit width" },
{ id: "100", label: "100%" },
{ id: "150", label: "150%" },
];
/** Gap between pages and padding around them, in CSS px (matches .voiced-pdf-preview__pages). */
const PAGE_GAP = 16;
const PAD = 16;
const MAX_LIVE_CANVASES = 3;
/** Backing store is never below 2x the CSS size: at fit-width the text would be blurry at 1x. */
const MIN_BACKING = 2;
const CSS_PX_PER_PT = 96 / 72;
function useDocument(bytes: Uint8Array | null) {
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const current = useRef<PDFDocumentProxy | null>(null);
useEffect(() => {
if (!bytes) return;
let stale = false;
openPdf(bytes).then(
(opened) => {
if (stale) {
void closePdf(opened);
return;
}
const previous = current.current;
current.current = opened;
setDoc(opened);
setLoadError(null);
// The old canvases are plain bitmaps, so the old document can go once the slots re-render.
if (previous) setTimeout(() => void closePdf(previous), 500);
},
(err: unknown) => {
if (!stale) setLoadError(err instanceof Error ? err.message : String(err));
},
);
return () => {
stale = true;
};
}, [bytes]);
useEffect(
() => () => {
if (current.current) void closePdf(current.current);
current.current = null;
},
[],
);
return { doc, loadError };
}
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
interface SlotProps {
doc: PDFDocumentProxy;
index: number;
live: boolean;
renderScale: number;
cssWidth: number;
cssHeight: number;
}
/**
* One page. A live slot renders into an offscreen canvas and swaps it in when done, so a re-render
* never blanks the page; a slot that is not live holds no canvas at all.
*/
function PageSlot({ doc, index, live, renderScale, cssWidth, cssHeight }: SlotProps) {
const host = useRef<HTMLDivElement>(null);
const key = useRef({}).current;
useEffect(() => {
const el = host.current;
if (!el) return;
if (!live) {
const old = el.firstElementChild as HTMLCanvasElement | null;
el.replaceChildren();
if (old) old.width = old.height = 0;
return;
}
let dead = false;
const off = document.createElement("canvas");
off.className = "voiced-pdf-preview__canvas";
(async () => {
try {
const page = await doc.getPage(index + 1);
await renderPageToCanvas(page, off, renderScale, key);
if (dead) return;
const old = el.firstElementChild as HTMLCanvasElement | null;
el.replaceChildren(off);
if (old) old.width = old.height = 0;
} catch (err) {
if (!dead && !isRenderCancelled(err)) console.error("PDF page render failed", err);
}
})();
return () => {
dead = true;
cancelPageRender(key);
};
}, [doc, index, live, renderScale, key]);
return <div ref={host} className="voiced-pdf-preview__page" data-page={index} style={{ width: cssWidth, height: cssHeight }} />;
}
interface ViewerProps {
bytes: Uint8Array | null;
/** Page count from the render, shown until the document has opened. */
pages: number;
className?: string;
}
/** Zoom control, page indicator and the scrolling stack of page canvases. */
function PdfViewer({ bytes, pages, className }: ViewerProps) {
const { doc, loadError } = useDocument(bytes);
const [zoom, setZoom] = useState<Zoom>("fit");
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
const [fitWidth, setFitWidth] = useState(0);
const [live, setLive] = useState<number[]>([0]);
const [current, setCurrent] = useState(1);
const scroller = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!doc) return;
let stale = false;
void doc.getPage(1).then((p) => {
const v = p.getViewport({ scale: 1 });
if (!stale) setNatural({ w: v.width, h: v.height });
});
return () => {
stale = true;
};
}, [doc]);
useEffect(() => {
const el = scroller.current;
if (!el) return;
const measure = () => setFitWidth(Math.max(0, el.clientWidth - 2 * PAD));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const pageCount = doc?.numPages ?? pages;
const cssWidth = natural ? (zoom === "fit" ? fitWidth : natural.w * CSS_PX_PER_PT * (Number(zoom) / 100)) : 0;
const cssHeight = natural ? (cssWidth * natural.h) / natural.w : 0;
const settledWidth = useDebounced(cssWidth, 150);
const renderScale = natural && settledWidth > 0 ? (settledWidth / natural.w) * Math.max(MIN_BACKING, window.devicePixelRatio || 1) : 0;
// Which pages are near the viewport; at most MAX_LIVE_CANVASES of them hold a canvas.
useEffect(() => {
const root = scroller.current;
if (!root || !doc || !natural) return;
const visible = new Set<number>();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const n = Number((e.target as HTMLElement).dataset.page);
if (e.isIntersecting) visible.add(n);
else visible.delete(n);
}
const next = [...visible].sort((a, b) => a - b).slice(0, MAX_LIVE_CANVASES);
setLive((prev) => (prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next));
},
{ root, rootMargin: "200px 0px" },
);
root.querySelectorAll("[data-page]").forEach((el) => io.observe(el));
return () => io.disconnect();
}, [doc, natural, pageCount, cssHeight]);
// Pages are one size, so the page under the middle of the viewport follows from the scroll offset.
useEffect(() => {
const root = scroller.current;
if (!root || cssHeight <= 0) return;
let frame = 0;
const update = () => {
frame = 0;
const middle = root.scrollTop + root.clientHeight / 2 - PAD;
const n = Math.floor(middle / (cssHeight + PAGE_GAP)) + 1;
setCurrent(Math.min(Math.max(n, 1), Math.max(pageCount, 1)));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
root.addEventListener("scroll", onScroll, { passive: true });
return () => {
root.removeEventListener("scroll", onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [cssHeight, pageCount]);
const liveSet = useMemo(() => new Set(live), [live]);
const zoomIndex = ZOOMS.findIndex((z) => z.id === zoom);
return (
<div className={`voiced-pdf-viewer ${className ?? ""}`}>
<div className="voiced-pdf-viewer__bar">
<ContentSwitcher
size="sm"
className="voiced-pdf-viewer__zoom"
selectedIndex={zoomIndex}
onChange={(e) => setZoom(ZOOMS[e.index ?? 0].id)}
>
{ZOOMS.map((z) => (
<Switch key={z.id} name={z.id} text={z.label} />
))}
</ContentSwitcher>
<span className="voiced-pdf-viewer__page" aria-live="polite">
{pageCount > 0 ? `Page ${current} of ${pageCount}` : "No pages"}
</span>
</div>
<div ref={scroller} className="voiced-pdf-preview__scroll">
{loadError ? (
<InlineNotification lowContrast hideCloseButton kind="error" title="Cannot show the PDF" subtitle={loadError} />
) : null}
<div className="voiced-pdf-preview__pages">
{doc && natural && cssWidth > 0
? Array.from({ length: pageCount }, (_, i) => (
<PageSlot
key={i}
doc={doc}
index={i}
live={liveSet.has(i) && renderScale > 0}
renderScale={renderScale}
cssWidth={cssWidth}
cssHeight={cssHeight}
/>
))
: null}
</div>
</div>
</div>
);
}
export default function PdfPreview({ bytes, pages, issues, rendering, mode, error, onFocus }: PdfPreviewProps) {
const [issuesOpen, setIssuesOpen] = useState(false);
const [focusOpen, setFocusOpen] = useState(false);
const errors = issues.filter((i) => i.severity === "error").length;
return (
<div className="voiced-pdf-preview">
<div className="voiced-pdf-preview__status">
<div className="voiced-pdf-preview__chips">
{rendering ? <InlineLoading status="active" description="Rendering…" /> : null}
{mode === "main" ? (
<Tag type="gray" size="sm">
Preview paused while typing
</Tag>
) : null}
</div>
<div className="voiced-pdf-preview__actions">
{issues.length > 0 ? (
<Popover open={issuesOpen} align="bottom-end" dropShadow onRequestClose={() => setIssuesOpen(false)}>
<Button
kind="ghost"
size="sm"
renderIcon={Warning}
aria-expanded={issuesOpen}
onClick={() => setIssuesOpen((o) => !o)}
>
{errors > 0 ? `Issues (${issues.length}, ${errors} error${errors === 1 ? "" : "s"})` : `Issues (${issues.length})`}
</Button>
<PopoverContent className="voiced-pdf-issues">
<ul className="voiced-pdf-issues__list">
{issues.map((issue, i) => (
<li key={i} className="voiced-pdf-issues__item">
<div className="voiced-pdf-issues__head">
<Tag type={issue.severity === "error" ? "red" : "gray"} size="sm" className={issue.severity === "error" ? undefined : "voiced-tag--warn"}>
{issue.code}
</Tag>
<span className="voiced-muted">Page {issue.page}</span>
</div>
<p className="voiced-pdf-issues__message">{issue.message}</p>
</li>
))}
</ul>
</PopoverContent>
</Popover>
) : null}
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={Maximize}
iconDescription="Focus preview"
tooltipPosition="left"
disabled={!bytes}
onClick={() => {
setFocusOpen(true);
onFocus?.();
}}
/>
</div>
</div>
{error ? <InlineNotification lowContrast hideCloseButton kind="error" title="Preview failed" subtitle={error} /> : null}
{bytes ? (
<PdfViewer bytes={bytes} pages={pages} />
) : (
<div className="voiced-pdf-preview__empty">{rendering ? "Rendering the first preview…" : "The preview appears here."}</div>
)}
<ComposedModal open={focusOpen} size="lg" className="voiced-pdf-focus" onClose={() => setFocusOpen(false)}>
<ModalHeader title="Invoice preview" closeModal={() => setFocusOpen(false)} />
<ModalBody hasScrollingContent={false}>{focusOpen && bytes ? <PdfViewer bytes={bytes} pages={pages} className="voiced-pdf-viewer--focus" /> : null}</ModalBody>
</ComposedModal>
</div>
);
}