fix(pdf-print): open the PDF in a new tab instead of printing the iframe

`iframe.contentWindow.print()` does not work for a PDF in Chrome. Chrome backs
every PDF — src URL or blob URL — with its internal `pdf_embedder` extension
rather than a real DOM document, so the frame's `contentDocument` is an empty
shell:

    <html><head><link rel="stylesheet"
      href="chrome-extension://…/pdf_embedder.css"></head>
    <body></body></html>

The PDF *paints* — you can see it in the preview — but `print()` runs against
that empty body, so the operator gets a print dialog full of blank pages and
no error to explain why. Read straight out of a live page to confirm.

A new tab gets Chrome's real PDF viewer, whose own print button works. Same
document, no blank pages.

Affects the delivery-note preview, the quote PDF viewer, and the second quote
viewer in the same file. Popup-blocked is now reported explicitly rather than
appearing to do nothing.
This commit is contained in:
Dave 2026-10-04 13:15:08 +02:00
parent ff58af7586
commit ad869aa6f8
2 changed files with 91 additions and 41 deletions

View file

@ -11,8 +11,9 @@
* The dialog is intentionally chrome-minimal so the PDF
* takes most of the viewport. The footer has:
* - Close
* - Print (calls iframe.contentWindow.print() so the
* operator prints just the PDF, not the whole page)
* - Print (opens the PDF in a new tab — see the note on
* `handlePrint` for why the iframe's own print() gives blank
* pages)
* - Download (uses the `download` attribute so the
* browser saves the file rather than opening it in a
* viewer)
@ -67,28 +68,77 @@ export function PdfPreviewDialog({
subtitle,
downloadFilename,
}: PdfPreviewDialogProps) {
// Ref on the iframe so Print can call the iframe's own
// print() — this prints just the PDF, not the whole page
// (the parent page has the modal chrome which would also
// print otherwise).
// Ref on the iframe so we can tell whether the PDF has finished
// loading before offering a print action.
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [blocked, setBlocked] = useState(false);
// Clear the pop-up-blocked notice on close rather than in an effect:
// syncing state from an effect triggers a second render pass, and
// the flag is only ever set by a click inside this component.
const handleOpenChange = (next: boolean) => {
if (!next) setBlocked(false);
onOpenChange(next);
};
const handlePrint = () => {
// Calling the iframe's contentWindow.print() prints the
// PDF document inside the iframe. Works in Chrome, Edge,
// Firefox, Safari. If the iframe didn't load (network
// blip, broken PDF), the call is a no-op — the operator
// can still use the Download button.
try {
iframeRef.current?.contentWindow?.print();
} catch {
// Ignore — some browsers throw on cross-origin or
// blocked-iframe cases. The Download button remains.
// Deliberately opens the PDF in a new tab rather than calling
// `iframe.contentWindow.print()`.
//
// That older approach does NOT work for a PDF in Chrome. Chrome
// renders PDFs with its internal `pdf_embedder` extension, not as
// a real DOM document, so the iframe's `contentDocument` is an
// empty shell:
//
// <html><head><link rel="stylesheet"
// href="chrome-extension://…/pdf_embedder.css"></head>
// <body></body></html>
//
// The PDF *paints* (you can see it), but `print()` runs against
// that empty body — so the operator gets a print dialog full of
// blank pages and no error to explain why. Verified in Chrome by
// reading the iframe's document straight out of the page.
//
// A new tab hands the operator Chrome's real PDF viewer, whose own
// print button works. Same document, no blank pages.
const url = pdfUrl;
const win = window.open(url, "_blank", "noopener,noreferrer");
if (!win) {
// Popup blocked. Say so rather than appearing to do nothing.
setBlocked(true);
}
};
if (blocked) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Pop-up blocked</DialogTitle>
<DialogDescription>
Your browser blocked the new tab needed to print {title}. Allow
pop-ups for this site, or use Download to save the PDF and open it
directly.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
Close
</Button>
<a href={pdfUrl} download={downloadFilename}>
<Button type="button" variant="outline">
<Download className="mr-1 h-3.5 w-3.5" />
Download instead
</Button>
</a>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
// Wide + tall — the PDF is the focus. Min-height is
// important so the iframe can render at a useful size
@ -109,7 +159,7 @@ export function PdfPreviewDialog({
<Button
size="icon"
variant="ghost"
onClick={() => onOpenChange(false)}
onClick={() => handleOpenChange(false)}
aria-label="Close preview"
data-testid="pdf-preview-close"
>
@ -137,14 +187,15 @@ export function PdfPreviewDialog({
<DialogFooter className="px-4 py-3 border-t gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
onClick={() => handleOpenChange(false)}
data-testid="pdf-preview-close-footer"
>
Close
</Button>
{/* Print — calls the iframe's own print() so the
operator prints just the PDF, not the page
chrome behind the modal. */}
{/* Print — opens the PDF in a new tab so the operator gets
Chrome's real PDF viewer, whose print button works. See
the note on `handlePrint` for why the iframe's own
`print()` produces blank pages. */}
<Button
type="button"
variant="outline"
@ -466,16 +517,10 @@ export function QuotePdfPreviewDialog({
type="button"
variant="outline"
onClick={() => {
// Same approach as `PdfPreviewDialog`: print
// the PDF inside the iframe so the operator
// prints just the PDF, not the page chrome.
try {
iframeRef.current?.contentWindow?.print();
} catch {
// Ignore — some browsers throw on cross-
// origin / blocked-iframe cases. The
// Download button remains.
}
// Opens in a new tab, same as `handlePrint` above —
// `contentWindow.print()` on a PDF iframe prints
// Chrome's empty pdf_embedder shell, i.e. blank pages.
if (pdfUrl) window.open(pdfUrl, "_blank", "noopener,noreferrer");
}}
data-testid="quote-pdf-preview-print"
>

View file

@ -11,8 +11,12 @@ import { X, Printer, Download, Loader2, ExternalLink } from "lucide-react";
* - On mount, fetches the PDF binary from the API as a Blob, creates
* a local blob URL, and renders it inside an `<iframe>` (browser
* native PDF viewer).
* - Print button: asks the iframe to call its own `window.print()` —
* prints the PDF directly, not the surrounding page chrome.
* - Print button: opens the PDF in a new tab. NOT
* `iframe.contentWindow.print()` — Chrome renders PDFs with its
* internal `pdf_embedder` extension, so the iframe's
* `contentDocument` is an empty shell and `print()` against it
* yields blank pages. Same bug and same fix as
* `components/delivery-notes/preview-dialog.tsx`.
* - Download button: fetches again with `?download=1` to get a
* `Content-Disposition: attachment` response, triggers a save dialog.
* - Open in new tab: opens the blob URL in a new browser tab for
@ -99,13 +103,14 @@ export function QuotePdfViewer({
if (!open) return null;
const handlePrint = () => {
// Print from inside the iframe so only the PDF prints, not the modal chrome.
try {
iframeRef.current?.contentWindow?.print();
} catch {
// Some browsers block cross-frame print; fall back to opening the blob URL.
if (blobUrl) window.open(blobUrl, "_blank");
}
// Open the blob URL in a new tab. Calling
// `iframeRef.current?.contentWindow?.print()` here looks like the
// obvious thing to do and is what this used to do, but Chrome
// backs a PDF iframe with its `pdf_embedder` extension, so the
// frame's document is an empty `<body>` — `print()` on it produces
// a dialog full of blank pages. The new tab gets the real PDF
// viewer, whose own print button works.
if (blobUrl) window.open(blobUrl, "_blank", "noopener,noreferrer");
};
const handleDownload = async () => {