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(null); const [loadError, setLoadError] = useState(null); const current = useRef(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(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(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
; } 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("fit"); const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); const [fitWidth, setFitWidth] = useState(0); const [live, setLive] = useState([0]); const [current, setCurrent] = useState(1); const scroller = useRef(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(); 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 (
setZoom(ZOOMS[e.index ?? 0].id)} > {ZOOMS.map((z) => ( ))} {pageCount > 0 ? `Page ${current} of ${pageCount}` : "No pages"}
{loadError ? ( ) : null}
{doc && natural && cssWidth > 0 ? Array.from({ length: pageCount }, (_, i) => ( 0} renderScale={renderScale} cssWidth={cssWidth} cssHeight={cssHeight} /> )) : null}
); } 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 (
{rendering ? : null} {mode === "main" ? ( Preview paused while typing ) : null}
{issues.length > 0 ? ( setIssuesOpen(false)}>
    {issues.map((issue, i) => (
  • {issue.code} Page {issue.page}

    {issue.message}

  • ))}
) : null}
{error ? : null} {bytes ? ( ) : (
{rendering ? "Rendering the first preview…" : "The preview appears here."}
)} setFocusOpen(false)}> setFocusOpen(false)} /> {focusOpen && bytes ? : null}
); }