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