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:
+42
-32
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user