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:
2026-10-04 05:47:19 +05:30
parent 3a99b7284b
commit 9e6df5ca8e
24 changed files with 1579 additions and 505 deletions
+42 -32
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionableNotification,
Button,
@@ -51,12 +51,17 @@ import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf";
import { exportInvoicePdf, modelFromAssets, prefsForInvoice } from "../lib/pdf";
import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import { InvoicePreview } from "../components/InvoicePreview";
import PageSetupControls from "../components/PageSetupControls";
import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { ImagePicker } from "../components/ImagePicker";
import type { InvoicePdfProps } from "../lib/pdfProps";
// pdf.js is only needed once a preview exists, so it loads on demand.
const PdfPreview = lazy(() => import("../components/PdfPreview"));
interface NewInvoiceProps {
settings: Settings;
@@ -75,7 +80,7 @@ const formatStamp = (iso: string) => {
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
};
export default function NewInvoice({ settings, active, onActivate }: NewInvoiceProps) {
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
const blankState = (): InvoiceFormState => {
const t = todayIso();
@@ -330,32 +335,25 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [unsaved]);
const previewProps: InvoicePdfProps = {
vendor: settings,
bank,
logoDataUri: logoUri,
signatureDataUri: signatureUri,
number: issued?.number ?? (number || "DRAFT"),
invoiceDate,
dueDate,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupplyStateCode: placeOfSupply,
items,
subtotal: totals.subtotal,
discount: totals.discount,
taxType: effectiveTaxType,
taxRate,
cgst: totals.cgst,
sgst: totals.sgst,
igst: totals.igst,
total: totals.total,
amountInWords: words,
notes,
};
// Page setup for the invoice being edited; an issued invoice shows what was frozen at issue.
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs));
useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]);
const activePrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
// The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped
// while this view is hidden (it stays mounted) so typing elsewhere never starts a render.
const previewModel = useMemo(() => {
if (!active) return null;
const invoice =
issued ??
formToPreviewInvoice(
{ invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items },
number || "DRAFT",
settings,
bank,
);
return modelFromAssets(invoice, settings, bank, logoUri, signatureUri);
}, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, settings, bank, logoUri, signatureUri]);
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
@@ -567,7 +565,7 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
draftId: draftIdRef.current,
items: items.map((item, index) => ({ ...item, sortOrder: index })),
};
invoice = await api.issueInvoice(input, {});
invoice = await api.issueInvoice(input, activePrefs);
} catch (e) {
issuedRef.current = false;
setError(String(e));
@@ -593,6 +591,8 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
const otherDrafts = drafts.filter((d) => d.id !== draftId);
const busy = saving || exporting;
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
return (
<>
@@ -1031,7 +1031,17 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
<Column sm={4} md={8} lg={7}>
<div style={{ position: "sticky", top: "3rem" }}>
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
<InvoicePreview {...previewProps} />
<PageSetupControls prefs={activePrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
<Suspense fallback={<InlineLoading description="Loading preview…" />}>
<PdfPreview
bytes={pdf.bytes}
pages={pdf.pages}
issues={pdf.issues}
rendering={pdf.rendering}
mode={pdf.mode}
error={pdf.error}
/>
</Suspense>
</div>
</Column>
</Grid>