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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user