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 a014a907e4
commit cedf4839bd
24 changed files with 1579 additions and 505 deletions
+3 -1
View File
@@ -17,7 +17,7 @@ const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, clien
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
cancel_reason, archived_pdf_sha256";
cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, '')";
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
Ok(Invoice {
@@ -55,6 +55,7 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
cancelled_at: row.get(31)?,
cancel_reason: row.get(32)?,
archived_pdf_sha256: row.get(33)?,
render_prefs: row.get(34)?,
items: Vec::new(),
})
}
@@ -922,6 +923,7 @@ mod tests {
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
.unwrap();
assert_eq!(prefs, "{\"theme\":\"plain\"}");
assert_eq!(inv.render_prefs, prefs);
}
#[test]
+24 -2
View File
@@ -6,7 +6,7 @@ use tauri::State;
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
signatory_designation";
signatory_designation, render_prefs";
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
Ok(Settings {
@@ -30,6 +30,7 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
default_hsn_sac: row.get(17)?,
signatory_name: row.get(18)?,
signatory_designation: row.get(19)?,
render_prefs: row.get(20)?,
})
}
@@ -54,7 +55,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
signatory_name = ?19, signatory_designation = ?20
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21
WHERE id = 1"#,
params![
settings.vendor_name,
@@ -77,6 +78,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
settings.default_hsn_sac,
settings.signatory_name,
settings.signatory_designation,
settings.render_prefs,
],
)
.map_err(|e| e.to_string())?;
@@ -230,6 +232,26 @@ mod tests {
(setting, flagged)
}
#[test]
fn settings_carry_render_prefs() {
let conn = crate::db::open_in_memory().unwrap();
let read = |conn: &Connection| {
conn.query_row(
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
[],
map_settings,
)
.unwrap()
};
assert_eq!(read(&conn).render_prefs, "");
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
.unwrap();
let settings = read(&conn);
assert_eq!(settings.render_prefs, "{\"version\":1}");
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["renderPrefs"], "{\"version\":1}");
}
#[test]
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
let mut conn = crate::db::open_in_memory().unwrap();
+23 -2
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
const LATEST_VERSION: i64 = 2;
const LATEST_VERSION: i64 = 3;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -169,8 +169,13 @@ CREATE TABLE IF NOT EXISTS invoice_drafts (
);
"#;
/// Version 3: the user's default page setup (RenderPrefsV1 JSON; empty means built-in defaults).
const M3: &str = r#"
ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT '';
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![M::up(SCHEMA), M::up(M2)])
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3)])
}
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
@@ -536,6 +541,22 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert!(backups_in(&backups).is_empty());
}
#[test]
fn render_prefs_column_defaults_to_empty_and_round_trips() {
let conn = open_in_memory().unwrap();
let initial: String = conn
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(initial, "");
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap();
let stored: String = conn
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(stored, json);
}
fn build_v1_database(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap();
+5
View File
@@ -23,6 +23,9 @@ pub struct Settings {
pub default_hsn_sac: String,
pub signatory_name: String,
pub signatory_designation: String,
/// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults.
#[serde(default)]
pub render_prefs: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -194,6 +197,8 @@ pub struct Invoice {
pub cancelled_at: Option<String>,
pub cancel_reason: String,
pub archived_pdf_sha256: Option<String>,
/// RenderPrefsV1 JSON frozen at issue; empty when none was stored.
pub render_prefs: String,
pub items: Vec<InvoiceItem>,
}
-192
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+341
View File
@@ -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>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import { getRenderClient, nextRenderJobId } from "../lib/renderClient";
import type { AuditIssue } from "../pdf/engine/audit";
import type { RenderModel } from "../pdf/model/build";
import type { RenderPrefsV1 } from "../pdf/model/prefs";
import { Superseded } from "../pdf/render/client";
/** Typing pauses this long before the model is re-rendered; the render itself takes about 1 s. */
export const MODEL_DEBOUNCE_MS = 800;
export interface InvoicePdfState {
bytes: Uint8Array | null;
pages: number;
issues: AuditIssue[];
rendering: boolean;
error: string | null;
mode: "worker" | "main";
lastMs: number | null;
}
const INITIAL: InvoicePdfState = { bytes: null, pages: 0, issues: [], rendering: false, error: null, mode: "worker", lastMs: null };
/**
* Live PDF for a render model. Model changes are debounced; changes to the prefs render at once. A new
* request cancels the one in flight, the previous bytes stay in place while the next render runs, and
* a null model never renders (the last PDF stays). Pass a memoised model: a new object means a change.
*/
export function useInvoicePdf({ model, prefs }: { model: RenderModel | null; prefs: RenderPrefsV1 }): InvoicePdfState {
const [state, setState] = useState<InvoicePdfState>(INITIAL);
const latestJob = useRef(0);
const lastPrefsKey = useRef<string | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const prefsKey = JSON.stringify(prefs);
useEffect(() => {
if (!model) return;
const prefsChanged = lastPrefsKey.current !== prefsKey;
lastPrefsKey.current = prefsKey;
const start = () => {
const id = nextRenderJobId();
latestJob.current = id;
setState((s) => ({ ...s, rendering: true }));
const client = getRenderClient();
client.render({ id, model, prefs: JSON.parse(prefsKey) as RenderPrefsV1 }).then(
(res) => {
if (!mounted.current || latestJob.current !== id) return;
setState({ bytes: res.bytes, pages: res.pages, issues: res.issues, rendering: false, error: null, mode: client.mode, lastMs: res.ms });
},
(err: unknown) => {
if (!mounted.current || latestJob.current !== id) return;
// Superseded while still the newest here means another caller (export) took the worker; keep
// the old PDF and let the next model or prefs change render again.
const error = err instanceof Superseded ? null : err instanceof Error ? err.message : String(err);
setState((s) => ({ ...s, rendering: false, error: error ?? s.error, mode: client.mode }));
},
);
};
if (prefsChanged) start();
const timer = prefsChanged ? undefined : setTimeout(start, MODEL_DEBOUNCE_MS);
return () => {
if (timer) clearTimeout(timer);
};
}, [model, prefsKey]);
return state;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export const api = {
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
issueInvoice: (input: InvoiceInput, renderPrefs: Record<string, unknown>) =>
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
cancelInvoice: (id: number, reason: string) =>
invoke<Invoice>("cancel_invoice", { id, reason }),
+47 -62
View File
@@ -1,13 +1,13 @@
import { save } from "@tauri-apps/plugin-dialog";
import pkg from "../../package.json";
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel } from "../pdf/model/build";
import { bankFromSnapshot, vendorFromSnapshot } from "../pdf/model/vendor";
import { initBrowserRendering } from "../pdf/render/browserInit";
import { renderInvoicePdf as renderModelPdf, type RenderInvoiceResult } from "../pdf/render/invoice";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
import { vendorFromSnapshot } from "../pdf/model/vendor";
import { Superseded } from "../pdf/render/client";
import type { RenderResult } from "../pdf/render/jobs";
import { api } from "./api";
import { imageSizeFromDataUri } from "./imageSize";
import type { InvoicePdfProps } from "./pdfProps";
import { getRenderClient, nextRenderJobId } from "./renderClient";
import type { BankAccount, Invoice, Settings } from "./types";
function bytesToBase64(bytes: Uint8Array): string {
@@ -17,68 +17,21 @@ function bytesToBase64(bytes: Uint8Array): string {
return btoa(bin);
}
/** Flat props for the on-screen preview; the PDF itself is built by renderInvoicePdf. */
export async function buildPdfProps(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<InvoicePdfProps> {
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
// legacy rows without a snapshot.
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath)
: null;
const resolvedBank = bankFromSnapshot(invoice.bankSnapshot, bank);
return {
vendor,
bank: resolvedBank,
logoDataUri,
signatureDataUri,
number: invoice.number,
invoiceDate: invoice.invoiceDate,
dueDate: invoice.dueDate,
clientName: invoice.clientName,
clientAddress: invoice.clientAddress,
clientGstin: invoice.clientGstin,
poNumber: invoice.poNumber,
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
items: invoice.items,
subtotal: invoice.subtotal,
discount: invoice.discount,
taxType: invoice.taxType,
taxRate: invoice.taxRate,
cgst: invoice.cgstAmount,
sgst: invoice.sgstAmount,
igst: invoice.igstAmount,
total: invoice.total,
amountInWords: invoice.amountInWords,
notes: invoice.notes,
};
}
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
/**
* Renders an issued invoice through the engine: model (supplier and bank from the snapshots frozen at
* issue, live settings only as a fallback) -> frame -> template -> PDF bytes, with the layout audit
* issues and fingerprint.
* The render model for an invoice whose images are already loaded (the live preview caches them).
* Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback.
*/
export async function renderInvoicePdf(
export function modelFromAssets(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = DEFAULT_PREFS,
): Promise<RenderInvoiceResult> {
await initBrowserRendering();
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
const model = buildRenderModel({
logoUri: string | null,
signatureDataUri: string | null,
): RenderModel {
return buildRenderModel({
invoice,
vendor: settings,
bank,
@@ -86,7 +39,39 @@ export async function renderInvoicePdf(
signatureDataUri,
appVersion: pkg.version,
});
return renderModelPdf({ model, prefs });
}
/** Loads the invoice's logo and signature, then builds its render model. */
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
return modelFromAssets(invoice, settings, bank, logoUri, signatureDataUri);
}
/** Page setup for an invoice: what was frozen at issue, else the user's current default. */
export const prefsForInvoice = (invoice: Invoice, settings: Settings): RenderPrefsV1 =>
parsePrefs(invoice.renderPrefs || settings.renderPrefs);
/**
* Renders an issued invoice to PDF bytes through the shared render client (worker, or the main
* thread if the worker cannot start), with the layout audit issues and fingerprint.
*/
export async function renderInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
): Promise<RenderResult> {
const model = await buildModelForInvoice(invoice, settings, bank);
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
for (let attempt = 0; ; attempt++) {
try {
return await getRenderClient().render({ id: nextRenderJobId(), model, prefs });
} catch (err) {
if (!(err instanceof Superseded) || attempt >= 4) throw err;
}
}
}
/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
@@ -105,7 +90,7 @@ export async function exportInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = DEFAULT_PREFS,
prefs?: RenderPrefsV1,
): Promise<string | null> {
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
return savePdfBytes(bytes, invoice.number);
-31
View File
@@ -1,31 +0,0 @@
import type { BankAccount, InvoiceItem, Settings, TaxType } from "./types";
/**
* The flat invoice props the on-screen preview (components/InvoicePreview.tsx) renders from. The PDF no
* longer uses them: it goes invoice -> buildRenderModel -> template (see lib/pdf.tsx).
*/
export interface InvoicePdfProps {
vendor: Settings;
bank: BankAccount | null;
logoDataUri: string | null;
signatureDataUri: string | null;
number: string;
invoiceDate: string;
dueDate: string;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
items: InvoiceItem[];
subtotal: number;
discount: number;
taxType: TaxType;
taxRate: number;
cgst: number;
sgst: number;
igst: number;
total: number;
amountInWords: string;
notes: string;
}
+50
View File
@@ -0,0 +1,50 @@
import { getDocument, GlobalWorkerOptions, type PDFDocumentProxy, type PDFPageProxy, type RenderTask } from "pdfjs-dist";
import { installStreamAsyncIterator } from "./streamPolyfill";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
installStreamAsyncIterator();
export type { PDFDocumentProxy, PDFPageProxy };
/**
* Opens PDF bytes. The bytes are copied because pdf.js transfers the buffer to its worker, which
* would detach the caller's array. Glyphs are drawn as paths (`disableFontFace`) so the preview looks
* the same on WebKitGTK and WebView2. (pdf.js 6 dropped `isEvalSupported`; it no longer evaluates font code.)
*/
export function openPdf(bytes: Uint8Array): Promise<PDFDocumentProxy> {
return getDocument({ data: bytes.slice(), disableFontFace: true }).promise;
}
/** Releases the document and its worker resources. */
export function closePdf(doc: PDFDocumentProxy): Promise<void> {
return doc.loadingTask.destroy();
}
const tasks = new WeakMap<object, RenderTask>();
/** Cancels whatever render is running for `slot` (a canvas, or any object the caller keys on). */
export function cancelPageRender(slot: object): void {
tasks.get(slot)?.cancel();
tasks.delete(slot);
}
/**
* Draws `page` into `canvas` at `scale` device pixels per PDF point, sizing the backing store to
* match. Starting a render for a slot cancels its previous one; a cancelled render rejects with
* pdf.js's RenderingCancelledException (see isRenderCancelled).
*/
export async function renderPageToCanvas(page: PDFPageProxy, canvas: HTMLCanvasElement, scale: number, slot: object = canvas): Promise<void> {
cancelPageRender(slot);
const viewport = page.getViewport({ scale });
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const task = page.render({ canvas, viewport });
tasks.set(slot, task);
try {
await task.promise;
} finally {
if (tasks.get(slot) === task) tasks.delete(slot);
}
}
export const isRenderCancelled = (err: unknown): boolean => (err as { name?: string } | null)?.name === "RenderingCancelledException";
+98
View File
@@ -0,0 +1,98 @@
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "./types";
import { computeLineAmount, computeTotals } from "./invoice";
import { amountInWords } from "./numberToWords";
/** The form fields the preview reads. */
export interface PreviewForm {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupply: string;
discount: number;
taxRate: number;
reverseCharge: boolean;
signaturePath: string | null;
notes: string;
items: InvoiceItem[];
}
/**
* The tax treatment Rust derives from the supplier state and the place of supply
* (src-tauri/src/gst.rs); the form shows and sends the same value.
*/
export function deriveTaxType(settings: Settings, placeOfSupply: string): TaxType {
if (settings.gstRegistration === "unregistered") return "none";
return !placeOfSupply || placeOfSupply === settings.vendorStateCode ? "cgst_sgst" : "igst";
}
/** Mirrors vendor_snapshot() in src-tauri/src/commands/invoice.rs. */
export function vendorSnapshotJson(settings: Settings): string {
return JSON.stringify({
vendorName: settings.vendorName,
vendorAddress: settings.vendorAddress,
vendorEmail: settings.vendorEmail,
vendorPhone: settings.vendorPhone,
vendorPan: settings.vendorPan,
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
vendorStateCode: settings.vendorStateCode,
logoPath: settings.logoPath,
signaturePath: settings.signaturePath,
gstRegistration: settings.gstRegistration,
signatoryName: settings.signatoryName,
signatoryDesignation: settings.signatoryDesignation,
});
}
/**
* A draft-status Invoice built from the live form, so the real renderer can draw it before anything
* is issued. Totals use the same paise rounding Rust stores. `number` is the next number (a peek),
* not yet consumed.
*/
export function formToPreviewInvoice(form: PreviewForm, number: string, settings: Settings, bank: BankAccount | null): Invoice {
const taxType = deriveTaxType(settings, form.placeOfSupply);
const registered = settings.gstRegistration !== "unregistered";
const totals = computeTotals(form.items, form.discount, taxType, form.taxRate);
const items = form.items.map((item, i) => ({ ...item, amount: computeLineAmount(item), sortOrder: i }));
return {
id: 0,
number,
seriesId: null,
invoiceDate: form.invoiceDate,
dueDate: form.dueDate,
clientId: form.clientId,
clientName: form.clientName,
clientAddress: form.clientAddress,
clientGstin: form.clientGstin,
poNumber: form.poNumber,
placeOfSupplyStateCode: form.placeOfSupply,
subtotal: totals.subtotal,
discount: totals.discount,
taxType,
taxRate: form.taxRate,
cgstAmount: totals.cgst,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: amountInWords(totals.total),
bankAccountId: bank?.id ?? null,
bankSnapshot: bank ? JSON.stringify(bank) : "",
signaturePath: form.signaturePath,
notes: form.notes,
status: "draft",
createdAt: "",
updatedAt: "",
docType: registered ? "tax_invoice" : "invoice",
reverseCharge: registered && form.reverseCharge,
vendorSnapshot: vendorSnapshotJson(settings),
snapshotOrigin: "issued",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items,
};
}
+12
View File
@@ -0,0 +1,12 @@
import { createAutoClient, type RenderClient } from "../pdf/render/client";
let client: RenderClient | null = null;
let lastId = 0;
/** The one render client (and so one worker) shared by the preview and export. */
export function getRenderClient(): RenderClient {
client ??= createAutoClient();
return client;
}
export const nextRenderJobId = (): number => ++lastId;
+25
View File
@@ -0,0 +1,25 @@
/**
* WebKitGTK (2.50) lacks ReadableStream.prototype[Symbol.asyncIterator], which pdf.js uses in
* getTextContent. Installs a minimal version when it is missing; a no-op elsewhere.
*/
export function installStreamAsyncIterator(): void {
const proto = (globalThis as { ReadableStream?: { prototype: object } }).ReadableStream?.prototype as
| Record<symbol, unknown>
| undefined;
if (!proto || Symbol.asyncIterator in proto) return;
proto[Symbol.asyncIterator] = function (this: ReadableStream<unknown>) {
const reader = this.getReader();
const iterator = {
next: () => reader.read(),
return: async () => {
await reader.cancel().catch(() => {});
reader.releaseLock();
return { done: true as const, value: undefined };
},
[Symbol.asyncIterator]() {
return iterator;
},
};
return iterator;
};
}
+4
View File
@@ -23,6 +23,8 @@ export interface Settings {
defaultHsnSac: string;
signatoryName: string;
signatoryDesignation: string;
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
renderPrefs: string;
}
export interface BankAccount {
@@ -132,6 +134,8 @@ export interface Invoice {
cancelledAt: string | null;
cancelReason: string;
archivedPdfSha256: string | null;
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
renderPrefs: string;
items: InvoiceItem[];
}
+2
View File
@@ -24,6 +24,7 @@ const vendor: Settings = {
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
renderPrefs: "",
};
const bank: BankAccount = {
@@ -77,6 +78,7 @@ const base: Invoice = {
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items,
};
+230
View File
@@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createAutoClient,
createMainThreadClient,
createRenderClient,
RenderFailed,
RenderTimeout,
Superseded,
WorkerUnavailable,
type WorkerLike,
} from "./client";
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
class FakeWorker implements WorkerLike {
onmessage: WorkerLike["onmessage"] = null;
onerror: WorkerLike["onerror"] = null;
sent: WorkerRequest[] = [];
terminated = false;
postMessage(message: WorkerRequest) {
this.sent.push(message);
}
terminate() {
this.terminated = true;
}
emit(data: WorkerMessage) {
this.onmessage?.({ data });
}
ready() {
this.emit({ type: "ready" });
}
reply(id: number) {
this.emit({ type: "result", result: result(id) });
}
}
const result = (id: number): RenderResult => ({
id,
bytes: new Uint8Array([id]),
pages: 1,
issues: [],
fingerprint: `fp${id}`,
ms: 5,
});
const job = (id: number): RenderJob => ({ id, model: {} as RenderJob["model"], prefs: {} as RenderJob["prefs"] });
function setup(timeoutMs = 1000) {
const workers: FakeWorker[] = [];
const client = createRenderClient({
timeoutMs,
workerFactory: () => {
const w = new FakeWorker();
workers.push(w);
return w;
},
});
return { client, workers };
}
const settled = (p: Promise<unknown>) => p.then(() => "ok", (e) => e);
describe("RenderClient", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("sends nothing until the worker is ready, then resolves with the result", async () => {
const { client, workers } = setup();
const p = client.render(job(1));
expect(workers).toHaveLength(1);
expect(workers[0].sent).toHaveLength(0);
workers[0].ready();
expect(workers[0].sent.map((m) => m.job.id)).toEqual([1]);
workers[0].reply(1);
expect((await p).fingerprint).toBe("fp1");
});
it("a new call supersedes the one in flight, and only the newest queued job runs next", async () => {
const s = setup();
const a = s.client.render(job(1));
s.workers[0].ready();
const b = s.client.render(job(2));
const c = s.client.render(job(3));
expect(await settled(a)).toBeInstanceOf(Superseded);
expect(await settled(b)).toBeInstanceOf(Superseded);
// The worker is still busy with job 1: nothing else was posted.
expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1]);
s.workers[0].reply(1); // result of the superseded job is discarded
expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1, 3]);
s.workers[0].reply(3);
expect((await c).id).toBe(3);
});
it("rejects the superseded in-flight promise immediately", async () => {
const { client, workers } = setup();
const a = client.render(job(1));
workers[0].ready();
const b = client.render(job(2));
expect(await settled(a)).toBeInstanceOf(Superseded);
workers[0].reply(1);
workers[0].reply(2);
expect((await b).id).toBe(2);
});
it("surfaces a render error from the worker and keeps going", async () => {
const { client, workers } = setup();
const a = client.render(job(1));
workers[0].ready();
workers[0].emit({ type: "error", id: 1, message: "boom", stack: "at worker" });
const err = await settled(a);
expect(err).toBeInstanceOf(RenderFailed);
expect((err as Error).message).toBe("boom");
const b = client.render(job(2));
workers[0].reply(2);
expect((await b).id).toBe(2);
});
it("watchdog: terminates, respawns, rejects with RenderTimeout, then serves the next job", async () => {
const { client, workers } = setup(1000);
const a = client.render(job(1));
workers[0].ready();
vi.advanceTimersByTime(1000);
expect(await settled(a)).toBeInstanceOf(RenderTimeout);
expect(workers[0].terminated).toBe(true);
expect(workers).toHaveLength(2);
const b = client.render(job(2));
workers[1].ready();
expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]);
workers[1].reply(2);
expect((await b).id).toBe(2);
});
it("watchdog on a superseded job still replaces the worker and then runs the queued job", async () => {
const { client, workers } = setup(1000);
const a = client.render(job(1));
workers[0].ready();
const b = client.render(job(2));
expect(await settled(a)).toBeInstanceOf(Superseded);
vi.advanceTimersByTime(1000);
expect(workers[0].terminated).toBe(true);
workers[1].ready();
expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]);
workers[1].reply(2);
expect((await b).id).toBe(2);
});
it("a start-up error before ready rejects with WorkerUnavailable, and so do later calls", async () => {
const { client, workers } = setup();
const a = client.render(job(1));
workers[0].emit({ type: "error", id: null, message: "fonts missing" });
const err = await settled(a);
expect(err).toBeInstanceOf(WorkerUnavailable);
expect((err as Error).message).toContain("fonts missing");
expect(workers[0].terminated).toBe(true);
expect(await settled(client.render(job(2)))).toBeInstanceOf(WorkerUnavailable);
expect(workers).toHaveLength(1);
});
it("a worker script error before ready is WorkerUnavailable; there is no timer fallback", async () => {
const { client, workers } = setup(1000);
const a = client.render(job(1));
// A silent worker that never says ready just waits: no timer turns that into a failure.
vi.advanceTimersByTime(60_000);
let done = false;
void settled(a).then(() => (done = true));
await Promise.resolve();
expect(done).toBe(false);
workers[0].onerror?.({ message: "SyntaxError" });
expect(await settled(a)).toBeInstanceOf(WorkerUnavailable);
});
it("a factory that throws is WorkerUnavailable", async () => {
const client = createRenderClient({
workerFactory: () => {
throw new Error("no Worker here");
},
});
expect(await settled(client.render(job(1)))).toBeInstanceOf(WorkerUnavailable);
});
it("dispose terminates the worker and rejects what is outstanding", async () => {
const { client, workers } = setup();
const a = client.render(job(1));
workers[0].ready();
const b = client.render(job(2));
client.dispose();
expect(workers[0].terminated).toBe(true);
expect(await settled(a)).toBeInstanceOf(Superseded);
expect(String(await settled(b))).toContain("disposed");
expect(String(await settled(client.render(job(3))))).toContain("disposed");
});
});
describe("main-thread client and fallback", () => {
it("runs jobs serially with latest-wins", async () => {
const gates: Array<() => void> = [];
const seen: number[] = [];
const client = createMainThreadClient(
(j) =>
new Promise((resolve) => {
seen.push(j.id);
gates.push(() => resolve(result(j.id)));
}),
);
expect(client.mode).toBe("main");
const a = client.render(job(1));
const b = client.render(job(2));
const c = client.render(job(3));
expect(await settled(a)).toBeInstanceOf(Superseded);
expect(await settled(b)).toBeInstanceOf(Superseded);
gates[0]();
await Promise.resolve();
await Promise.resolve();
expect(seen).toEqual([1, 3]);
gates[1]();
expect((await c).id).toBe(3);
});
it("the auto client moves to the main thread when the worker cannot start", async () => {
const client = createAutoClient({
workerFactory: () => {
throw new Error("no worker");
},
mainExecutor: async (j) => result(j.id),
});
expect(client.mode).toBe("worker");
expect((await client.render(job(7))).id).toBe(7);
expect(client.mode).toBe("main");
expect((await client.render(job(8))).id).toBe(8);
});
});
+286
View File
@@ -0,0 +1,286 @@
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
/** A newer render call replaced this one before it finished (or before it started). */
export class Superseded extends Error {
constructor() {
super("Render superseded by a newer request");
this.name = "Superseded";
}
}
/** The watchdog fired: the worker was terminated and replaced. */
export class RenderTimeout extends Error {
constructor(ms: number) {
super(`The PDF render did not finish within ${Math.round(ms / 1000)} s and was stopped`);
this.name = "RenderTimeout";
}
}
/** The worker could not start (script, WASM or font failure). Rendering must move to the main thread. */
export class WorkerUnavailable extends Error {
constructor(detail: string) {
super(`The render worker could not start: ${detail}`);
this.name = "WorkerUnavailable";
}
}
/** The render itself threw inside the worker. */
export class RenderFailed extends Error {
constructor(message: string, workerStack?: string) {
super(message);
this.name = "RenderFailed";
if (workerStack) this.stack = workerStack;
}
}
/** The part of Worker the client uses, so tests can supply a fake. */
export interface WorkerLike {
postMessage(message: WorkerRequest): void;
terminate(): void;
onmessage: ((event: { data: WorkerMessage }) => void) | null;
onerror: ((event: { message?: string }) => void) | null;
}
export interface RenderClient {
/** Resolves with the newest request's result; an older call rejects with Superseded. */
render(job: RenderJob): Promise<RenderResult>;
dispose(): void;
readonly mode: "worker" | "main";
}
export interface RenderClientOptions {
workerFactory?: () => WorkerLike;
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
timeoutMs?: number;
}
const defaultWorkerFactory = (): WorkerLike =>
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike;
interface Slot {
job: RenderJob;
resolve: (r: RenderResult) => void;
reject: (e: unknown) => void;
}
interface Running extends Slot {
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
superseded: boolean;
timer: ReturnType<typeof setTimeout>;
}
/**
* Renders in a module worker. Only the newest request matters: a new call rejects the one in flight
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
* running job finishes at most one job, the newest, runs. The handshake is the worker's `ready`
* message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback.
*/
export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
let worker: WorkerLike | null = null;
let ready = false;
let unavailable: WorkerUnavailable | null = null;
let disposed = false;
let running: Running | null = null;
let pending: Slot | null = null;
const stopWorker = () => {
const w = worker;
worker = null;
ready = false;
if (w) {
w.onmessage = null;
w.onerror = null;
w.terminate();
}
};
const settleRunning = (outcome: (slot: Running) => void) => {
const cur = running;
if (!cur) return;
running = null;
clearTimeout(cur.timer);
if (!cur.superseded) outcome(cur);
};
const fail = (err: WorkerUnavailable) => {
unavailable = err;
stopWorker();
settleRunning((s) => s.reject(err));
pending?.reject(err);
pending = null;
};
const pump = () => {
if (!ready || !worker || running || !pending || disposed) return;
const slot = pending;
pending = null;
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
worker.postMessage({ type: "render", job: slot.job });
};
const spawn = () => {
let w: WorkerLike;
try {
w = workerFactory();
} catch (e) {
fail(new WorkerUnavailable(e instanceof Error ? e.message : String(e)));
return;
}
worker = w;
ready = false;
w.onmessage = (event) => {
if (w !== worker) return;
const msg = event.data;
if (msg.type === "ready") {
ready = true;
pump();
} else if (msg.type === "result") {
if (running?.job.id !== msg.result.id) return;
settleRunning((s) => s.resolve(msg.result));
pump();
} else if (msg.id === null) {
if (!ready) fail(new WorkerUnavailable(msg.message));
} else if (running?.job.id === msg.id) {
settleRunning((s) => s.reject(new RenderFailed(msg.message, msg.stack)));
pump();
}
};
w.onerror = (event) => {
if (w !== worker) return;
const detail = event.message || "script error";
if (!ready) return fail(new WorkerUnavailable(detail));
// A crash mid-job: replace the worker so later jobs have a live one.
settleRunning((s) => s.reject(new RenderFailed(detail)));
stopWorker();
spawn();
};
};
function onTimeout() {
settleRunning((s) => s.reject(new RenderTimeout(timeoutMs)));
stopWorker();
spawn();
}
return {
mode: "worker",
render(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
if (unavailable) return Promise.reject(unavailable);
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
if (!worker) spawn();
pump();
});
},
dispose() {
if (disposed) return;
disposed = true;
const err = new Error("The render client was disposed");
settleRunning((s) => s.reject(err));
pending?.reject(err);
pending = null;
stopWorker();
},
};
}
/** Renders one job on this thread. */
export type MainExecutor = (job: RenderJob) => Promise<RenderResult>;
// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable.
const runOnMainThread: MainExecutor = async (job) => {
const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]);
await initBrowserRendering();
// Let pending input and paint run before the long synchronous layout.
await new Promise<void>((r) => setTimeout(r, 0));
const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
return {
id: job.id,
bytes: out.bytes,
pages: out.pages,
issues: out.issues,
fingerprint: out.fingerprint,
ms: Math.round(performance.now() - t0),
};
};
/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient {
let disposed = false;
let running: (Slot & { superseded: boolean }) | null = null;
let pending: Slot | null = null;
const pump = async () => {
if (running || !pending || disposed) return;
const slot = { ...pending, superseded: false };
pending = null;
running = slot;
try {
const result = await execute(slot.job);
if (!slot.superseded) slot.resolve(result);
} catch (e) {
if (!slot.superseded) slot.reject(e);
}
running = null;
void pump();
};
return {
mode: "main",
render(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
void pump();
});
},
dispose() {
disposed = true;
const err = new Error("The render client was disposed");
pending?.reject(err);
pending = null;
},
};
}
/**
* Worker first; the first WorkerUnavailable switches to the main thread for good. `mode` tells the UI
* which one is active (main thread: the preview is paused while typing).
*/
export function createAutoClient(
opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {},
): RenderClient {
const primary = createRenderClient(opts);
let main: RenderClient | null = null;
return {
get mode() {
return main ? "main" : "worker";
},
render(job) {
if (main) return main.render(job);
return primary.render(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err;
main ??= createMainThreadClient(opts.mainExecutor);
primary.dispose();
return main.render(job);
});
},
dispose() {
primary.dispose();
main?.dispose();
},
};
}
+29
View File
@@ -0,0 +1,29 @@
import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build";
import type { RenderPrefsV1 } from "../model/prefs";
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
export interface RenderJob {
id: number;
model: RenderModel;
prefs: RenderPrefsV1;
}
export interface RenderResult {
id: number;
bytes: Uint8Array;
pages: number;
issues: AuditIssue[];
fingerprint: string;
/** Wall time of the render itself, measured where it ran. */
ms: number;
}
export type WorkerRequest = { type: "render"; job: RenderJob };
export type WorkerMessage =
| { type: "ready" }
| { type: "result"; result: RenderResult }
/** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */
| { type: "error"; id: number | null; message: string; stack?: string };
+50
View File
@@ -0,0 +1,50 @@
// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
import { initBrowserRendering } from "./browserInit";
import { renderInvoicePdf } from "./invoice";
import type { WorkerMessage, WorkerRequest } from "./jobs";
interface WorkerScope {
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
postMessage(message: WorkerMessage, options?: { transfer: Transferable[] }): void;
}
const scope = self as unknown as WorkerScope;
installStreamAsyncIterator();
const post = (message: WorkerMessage, transfer: Transferable[] = []) => scope.postMessage(message, { transfer });
const failure = (id: number | null, err: unknown): WorkerMessage => ({
type: "error",
id,
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
const started = initBrowserRendering();
scope.onmessage = async (event) => {
const request = event.data;
if (request?.type !== "render") return;
const { job } = request;
try {
await started.catch(() => initBrowserRendering());
const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
const ms = Math.round(performance.now() - t0);
// Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first.
const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength;
const bytes = whole ? out.bytes : out.bytes.slice();
post(
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
[bytes.buffer],
);
} catch (err) {
post(failure(job.id, err));
}
};
started.then(
() => post({ type: "ready" }),
(err) => post(failure(null, err)),
);
+2
View File
@@ -50,6 +50,7 @@ const baseVendor: Settings = {
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
renderPrefs: "",
};
export const FIXTURE_BANK: BankAccount = {
@@ -210,6 +211,7 @@ export function buildFixture(spec: FixtureSpec): BuiltFixture {
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items,
...spec.invoice,
};
+133 -181
View File
@@ -186,218 +186,170 @@ body {
color: var(--cds-text-secondary);
}
/* ---------- Invoice preview ("paper") ---------- */
.inv-preview-wrap {
/* ---------- PDF preview column ---------- */
.voiced-page-setup {
margin-bottom: 1rem;
}
.voiced-page-setup__row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1rem;
}
.voiced-page-setup__field {
min-width: 11rem;
flex: 1 1 11rem;
}
.voiced-page-setup__field .cds--label {
display: block;
margin-bottom: 0.5rem;
}
.voiced-page-setup__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
margin-top: 0.75rem;
}
.voiced-page-setup__note {
font-size: 0.75rem;
flex: 1 1 12rem;
}
.voiced-pdf-preview {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.voiced-pdf-preview__status {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.25rem 1rem;
min-height: 2rem;
}
.voiced-pdf-preview__chips,
.voiced-pdf-preview__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.voiced-pdf-preview__empty {
display: flex;
align-items: center;
justify-content: center;
height: 12rem;
background: var(--cds-layer-accent);
color: var(--cds-text-secondary);
}
.inv-paper {
background: #ffffff;
color: #161616;
width: 100%;
max-width: 820px;
padding: 40px 44px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
line-height: 1.5;
box-sizing: border-box;
}
.inv-top {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
align-items: start;
}
.inv-vendor {
.voiced-pdf-viewer {
display: flex;
gap: 16px;
align-items: flex-start;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.inv-vendor > div {
flex: 1 1 0;
min-width: 0;
}
.inv-vendor__logo {
max-height: 56px;
max-width: 220px;
object-fit: contain;
}
.inv-vendor__name {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.inv-vendor__line {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.inv-meta {
text-align: right;
min-width: 0;
}
.inv-meta__title {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.08em;
margin: 0 0 8px;
text-transform: uppercase;
}
.inv-meta__row {
.voiced-pdf-viewer__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
gap: 0.5rem 1rem;
}
.inv-meta__label {
color: #525252;
.voiced-pdf-viewer__zoom {
flex: 0 1 18rem;
}
.inv-meta__value {
font-weight: 600;
}
.inv-split {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 28px;
}
.inv-block {
min-width: 0;
}
.inv-block__label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #525252;
margin: 0 0 4px;
}
.inv-block__strong {
font-weight: 600;
}
.inv-table {
width: 100%;
border-collapse: collapse;
margin-top: 28px;
}
.inv-table th {
text-align: left;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #525252;
border-bottom: 1.5px solid #161616;
padding: 6px 8px;
}
.inv-table td {
padding: 8px;
border-bottom: 1px solid #e0e0e0;
vertical-align: top;
}
.inv-table .num {
text-align: right;
.voiced-pdf-viewer__page {
font-size: 0.875rem;
color: var(--cds-text-secondary);
white-space: nowrap;
}
.inv-totals {
margin-top: 16px;
.voiced-pdf-preview__scroll {
height: calc(100vh - 25rem);
min-height: 20rem;
overflow: auto;
background: var(--cds-layer-accent);
}
.voiced-pdf-viewer--focus .voiced-pdf-preview__scroll {
height: calc(100vh - 12rem);
}
/* The wrapper is as wide as the widest page so a zoomed page scrolls instead of being clipped. */
.voiced-pdf-preview__pages {
box-sizing: border-box;
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: center;
gap: 16px;
min-width: 100%;
width: max-content;
padding: 16px;
}
.inv-totals__table {
width: 320px;
.voiced-pdf-preview__page {
flex: none;
background: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
.inv-totals__row {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
.inv-totals__row--grand {
border-top: 1.5px solid #161616;
margin-top: 4px;
padding-top: 8px;
font-size: 16px;
font-weight: 600;
}
.inv-words {
margin-top: 16px;
font-style: italic;
}
.inv-bottom {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 32px;
align-items: flex-end;
}
.inv-signature {
text-align: right;
}
.inv-signature__img {
max-height: 64px;
max-width: 220px;
object-fit: contain;
.voiced-pdf-preview__canvas {
display: block;
margin-left: auto;
width: 100%;
height: 100%;
}
.inv-signature__name {
margin-top: 6px;
font-weight: 600;
border-top: 1px solid #8d8d8d;
padding-top: 4px;
display: inline-block;
min-width: 160px;
.voiced-pdf-focus .cds--modal-container {
width: 96vw;
max-width: 96vw;
height: 94vh;
max-height: 94vh;
}
.inv-fineprint {
margin-top: 8px;
font-size: 10px;
color: #6f6f6f;
text-align: right;
.voiced-pdf-issues {
width: 22rem;
max-width: 80vw;
max-height: 20rem;
overflow: auto;
padding: 0.75rem 1rem;
}
.inv-footer {
margin-top: 28px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
font-size: 10px;
color: #6f6f6f;
.voiced-pdf-issues__list {
list-style: none;
margin: 0;
padding: 0;
}
.voiced-pdf-issues__item + .voiced-pdf-issues__item {
margin-top: 0.75rem;
}
.voiced-pdf-issues__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
gap: 0.5rem;
}
@media print {
.inv-paper {
box-shadow: none;
max-width: none;
.voiced-pdf-issues__message {
margin: 0.25rem 0 0;
font-size: 0.875rem;
}
.voiced-tag--warn.cds--tag {
background-color: var(--cds-support-warning);
color: #161616;
}
+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>