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.
342 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|