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.
This commit is contained in:
@@ -1,192 +0,0 @@
|
||||
import { UNIT_LABELS } from "../lib/types";
|
||||
import { computeLineAmount } from "../lib/invoice";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import type { InvoicePdfProps } from "../lib/pdfProps";
|
||||
|
||||
const money = (n: number) => `₹${formatAmount(n)}`;
|
||||
|
||||
/** On-screen preview that mirrors the exported PDF layout. */
|
||||
export function InvoicePreview(props: InvoicePdfProps) {
|
||||
const {
|
||||
vendor,
|
||||
bank,
|
||||
logoDataUri,
|
||||
signatureDataUri,
|
||||
number,
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
clientName,
|
||||
clientAddress,
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupplyStateCode,
|
||||
items,
|
||||
subtotal,
|
||||
discount,
|
||||
taxType,
|
||||
taxRate,
|
||||
cgst,
|
||||
sgst,
|
||||
igst,
|
||||
total,
|
||||
amountInWords,
|
||||
notes,
|
||||
} = props;
|
||||
|
||||
const isTaxInvoice = taxType !== "none";
|
||||
|
||||
return (
|
||||
<div className="inv-preview-wrap">
|
||||
<div className="inv-paper">
|
||||
<div className="inv-top">
|
||||
<div className="inv-vendor">
|
||||
{logoDataUri ? <img className="inv-vendor__logo" src={logoDataUri} alt="" /> : null}
|
||||
<div>
|
||||
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
|
||||
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
|
||||
{vendor.vendorEmail ? <p className="inv-vendor__line">{vendor.vendorEmail}</p> : null}
|
||||
{vendor.vendorPhone ? <p className="inv-vendor__line">{vendor.vendorPhone}</p> : null}
|
||||
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
|
||||
{vendor.vendorGstin ? <p className="inv-vendor__line">GSTIN: {vendor.vendorGstin}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inv-meta">
|
||||
<h3 className="inv-meta__title">{isTaxInvoice ? "Tax Invoice" : "Invoice"}</h3>
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Invoice No.</span>
|
||||
<span className="inv-meta__value">{number}</span>
|
||||
</div>
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Date</span>
|
||||
<span className="inv-meta__value">{formatDate(invoiceDate)}</span>
|
||||
</div>
|
||||
{dueDate ? (
|
||||
<div className="inv-meta__row">
|
||||
<span className="inv-meta__label">Due Date</span>
|
||||
<span className="inv-meta__value">{formatDate(dueDate)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-split">
|
||||
<div className="inv-block" style={{ maxWidth: "60%" }}>
|
||||
<p className="inv-block__label">Billed To</p>
|
||||
<div className="inv-block__strong">{clientName || "—"}</div>
|
||||
{clientAddress ? <div style={{ whiteSpace: "pre-line" }}>{clientAddress}</div> : null}
|
||||
{clientGstin ? <div>GSTIN: {clientGstin}</div> : null}
|
||||
{placeOfSupplyStateCode ? <div>Place of Supply: {placeOfSupplyStateCode}</div> : null}
|
||||
</div>
|
||||
{poNumber ? (
|
||||
<div className="inv-block">
|
||||
<p className="inv-block__label">PO Number</p>
|
||||
<div className="inv-block__strong">{poNumber}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<table className="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th className="num">Unit Price</th>
|
||||
<th className="num">Qty</th>
|
||||
<th className="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
<td>{item.description || "—"}</td>
|
||||
<td className="num">
|
||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
||||
</td>
|
||||
<td className="num">{item.mode === "rate" ? item.quantity : 1}</td>
|
||||
<td className="num">{money(computeLineAmount(item))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="inv-totals">
|
||||
<div className="inv-totals__table">
|
||||
<div className="inv-totals__row">
|
||||
<span>Subtotal</span>
|
||||
<span>{money(subtotal)}</span>
|
||||
</div>
|
||||
{discount > 0 ? (
|
||||
<div className="inv-totals__row">
|
||||
<span>Discount</span>
|
||||
<span>- {money(discount)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{taxType === "cgst_sgst" ? (
|
||||
<>
|
||||
<div className="inv-totals__row">
|
||||
<span>CGST ({(taxRate / 2).toFixed(2)}%)</span>
|
||||
<span>{money(cgst)}</span>
|
||||
</div>
|
||||
<div className="inv-totals__row">
|
||||
<span>SGST ({(taxRate / 2).toFixed(2)}%)</span>
|
||||
<span>{money(sgst)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{taxType === "igst" ? (
|
||||
<div className="inv-totals__row">
|
||||
<span>IGST ({taxRate.toFixed(2)}%)</span>
|
||||
<span>{money(igst)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="inv-totals__row inv-totals__row--grand">
|
||||
<span>Total</span>
|
||||
<span>{money(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="inv-words">Amount in words: {amountInWords}</p>
|
||||
|
||||
{notes ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="inv-block__label">Notes</p>
|
||||
<div style={{ whiteSpace: "pre-line" }}>{notes}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="inv-bottom">
|
||||
<div className="inv-block" style={{ maxWidth: "55%" }}>
|
||||
{bank ? (
|
||||
<>
|
||||
<p className="inv-block__label">Pay To</p>
|
||||
<div className="inv-block__strong">{bank.accountName || vendor.vendorName}</div>
|
||||
<div>{bank.bankName}</div>
|
||||
{bank.accountNo ? <div>Account No.: {bank.accountNo}</div> : null}
|
||||
{bank.branch ? <div>Branch: {bank.branch}</div> : null}
|
||||
{bank.ifsc ? <div>IFSC: {bank.ifsc}</div> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="inv-signature">
|
||||
{signatureDataUri ? (
|
||||
<>
|
||||
<img className="inv-signature__img" src={signatureDataUri} alt="Signature" />
|
||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
||||
<p className="inv-fineprint">Computer-generated invoice; no signature is required.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-footer">
|
||||
<span>{vendor.vendorName}</span>
|
||||
<span>Generated with Voiced — a Bongbetic product</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { Settings } from "../lib/types";
|
||||
import type { MarginPreset, PageId } from "../pdf/engine/geometry";
|
||||
import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
|
||||
const PAGES: { id: PageId; label: string }[] = [
|
||||
{ id: "A4", label: "A4" },
|
||||
{ id: "LETTER", label: "US Letter" },
|
||||
];
|
||||
|
||||
const MARGINS: { id: MarginPreset; label: string }[] = [
|
||||
{ id: "template", label: "Template default" },
|
||||
{ id: "narrow", label: "Narrow (0.5″)" },
|
||||
{ id: "normal", label: "Normal (0.75″)" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
prefs: RenderPrefsV1;
|
||||
onChange: (prefs: RenderPrefsV1) => void;
|
||||
/** Live settings; `renderPrefs` is the saved default. */
|
||||
settings: Settings;
|
||||
/** Called after the default was saved so the app reloads its settings. */
|
||||
onSaved: () => void;
|
||||
disabled?: boolean;
|
||||
/** Where a TemplatePicker goes once there is more than one template. */
|
||||
templateSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */
|
||||
export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled, templateSlot }: Props) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const saved = prefsToJson(parsePrefs(settings.renderPrefs));
|
||||
const isDefault = prefsToJson(prefs) === saved;
|
||||
|
||||
const saveDefault = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.saveSettings({ ...settings, renderPrefs: prefsToJson(prefs) });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page-setup">
|
||||
<div className="voiced-page-setup__row">
|
||||
{templateSlot}
|
||||
<div className="voiced-page-setup__field">
|
||||
<span className="cds--label">Page size</span>
|
||||
<ContentSwitcher
|
||||
size="md"
|
||||
selectedIndex={Math.max(0, PAGES.findIndex((p) => p.id === prefs.pageSize))}
|
||||
onChange={(e) => !disabled && onChange({ ...prefs, pageSize: PAGES[e.index ?? 0].id })}
|
||||
>
|
||||
{PAGES.map((p) => (
|
||||
<Switch key={p.id} name={p.id} text={p.label} disabled={disabled} />
|
||||
))}
|
||||
</ContentSwitcher>
|
||||
</div>
|
||||
<div className="voiced-page-setup__field">
|
||||
<Select
|
||||
id="page-margins"
|
||||
labelText="Margins"
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
value={prefs.margins}
|
||||
onChange={(e) => onChange({ ...prefs, margins: e.target.value as MarginPreset })}
|
||||
>
|
||||
{MARGINS.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="voiced-page-setup__footer">
|
||||
{saving ? (
|
||||
<InlineLoading status="active" description="Saving…" />
|
||||
) : (
|
||||
<Button kind="tertiary" size="sm" disabled={isDefault} onClick={() => void saveDefault()}>
|
||||
{isDefault ? "Saved as default" : "Save as default"}
|
||||
</Button>
|
||||
)}
|
||||
<span className="voiced-muted voiced-page-setup__note">
|
||||
{error ?? "This choice is frozen into each invoice when it is issued."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user