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
+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;
}