fix: print only the open message on Ctrl+P, without plugin panels or the app around it

This commit is contained in:
Linus Rath
2026-11-03 03:30:33 +01:00
parent d82f677b9b
commit 03e7cd86f1
4 changed files with 288 additions and 19 deletions
+25
View File
@@ -713,3 +713,28 @@
color: var(--pr-muted);
white-space: nowrap;
}
/* ---- A mail message (components/email/message-print.tsx) ---------------- */
.pr-message-meta {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 2mm;
margin: 1.5mm 0 0;
color: var(--pr-muted);
font-size: 8.5pt;
}
.pr-message-meta > div {
display: contents;
}
.pr-message-meta dt {
color: var(--pr-ink);
font-weight: 600;
}
.pr-message-meta dd {
margin: 0;
overflow-wrap: anywhere;
}
@@ -0,0 +1,130 @@
// Ctrl+P on an open message printed the app itself: the toolbar, the plugin
// panel beside the message, and only as much of the body as fit the
// reader's fixed height. The reader now puts a print copy of the message
// next to the app, which app/print.css prints instead.
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Email, Mailbox } from '@/lib/jmap/types';
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsMobile: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const { usePrintStore } = await import('@/stores/print-store');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const HTML = '<p class="hidden" id="main">Season one starts today</p><table border="1"><tr><td>Row</td></tr></table>';
function htmlMail(): Email {
return {
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Call of Duty', email: 'news@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: 'New trailer', receivedAt: '2026-11-03T00:09:00Z', preview: 'Season', hasAttachment: false, size: 100,
textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
bodyValues: { '1': { value: HTML, isTruncated: false } },
} as unknown as Email;
}
let visible = true;
beforeEach(() => {
visible = true;
// jsdom lays nothing out; the reader asks whether it is on screen.
(HTMLElement.prototype as unknown as { checkVisibility: () => boolean }).checkVisibility = () => visible;
});
afterEach(() => {
act(() => { window.dispatchEvent(new Event('afterprint')); });
delete (HTMLElement.prototype as unknown as { checkVisibility?: unknown }).checkVisibility;
vi.restoreAllMocks();
});
function printCopy(): HTMLElement | null {
return document.body.querySelector(':scope > [data-print-root]');
}
describe('Ctrl+P on an open message', () => {
it('prints the message alone, next to the app', () => {
render(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
expect(printCopy()).toBeNull();
act(() => { window.dispatchEvent(new Event('beforeprint')); });
const copy = printCopy()!;
expect(copy).not.toBeNull();
expect(copy.querySelector('h1')!.textContent).toBe('New trailer');
expect(copy.textContent).toContain('Call of Duty <news@example.com>');
expect(copy.textContent).toContain('Bob <bob@example.com>');
// The body keeps out of the app's CSS: it sits in a shadow root.
const body = copy.querySelector<HTMLElement>('[data-message-print-body]')!;
expect(body.shadowRoot).not.toBeNull();
expect(body.shadowRoot!.textContent).toContain('Season one starts today');
expect(body.shadowRoot!.querySelector('table')).not.toBeNull();
act(() => { window.dispatchEvent(new Event('afterprint')); });
expect(printCopy()).toBeNull();
}, 30_000);
it('keeps remote content out while it is blocked, as the copy has no frame CSP', () => {
const mail = htmlMail();
mail.bodyValues = { '1': { value: '<p>Hello</p><img src="https://tracker.example/pixel.png"><div style="background:url(https://tracker.example/bg.png)">x</div>', isTruncated: false } } as Email['bodyValues'];
render(<EmailViewer email={mail} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
act(() => { window.dispatchEvent(new Event('beforeprint')); });
const shadow = printCopy()!.querySelector<HTMLElement>('[data-message-print-body]')!.shadowRoot!;
expect(shadow.textContent).toContain('Hello');
expect(shadow.innerHTML).not.toContain('tracker.example');
}, 30_000);
it('prints a plain-text mail with its line breaks and its quote opened', () => {
const mail = htmlMail();
mail.textBody = [{ partId: '1', type: 'text/plain' }] as Email['textBody'];
mail.htmlBody = [{ partId: '1', type: 'text/plain' }] as Email['htmlBody'];
mail.bodyValues = { '1': { value: 'line one\nline two\n\nOn Monday you wrote:\n> the old question\n', isTruncated: false } } as Email['bodyValues'];
render(<EmailViewer email={mail} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
act(() => { window.dispatchEvent(new Event('beforeprint')); });
const shadow = printCopy()!.querySelector<HTMLElement>('[data-message-print-body]')!.shadowRoot!;
expect(shadow.querySelector('.plain')!.textContent).toContain('line one\nline two');
expect(shadow.querySelector('style')!.textContent).toMatch(/\.plain \{ white-space: pre-wrap/);
expect(shadow.querySelector('details')!.hasAttribute('open')).toBe(true);
}, 30_000);
it('leaves the copy out for a reader that is not on screen', () => {
visible = false;
render(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
act(() => { window.dispatchEvent(new Event('beforeprint')); });
expect(usePrintStore.getState().printing).toBe(true);
expect(printCopy()).toBeNull();
}, 30_000);
it('prints nothing of its own with no message open', () => {
render(<EmailViewer email={null} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
act(() => { window.dispatchEvent(new Event('beforeprint')); });
expect(printCopy()).toBeNull();
}, 30_000);
});
+60 -19
View File
@@ -15,6 +15,8 @@ import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachments";
import { ReaderViewDialog } from "./reader-view-dialog";
import { MessagePrint, type MessagePrintFields } from "./message-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
import { ReadingMask } from "./reading-mask";
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { getAttachmentDisplayName } from "@/lib/attachment-name";
@@ -370,6 +372,12 @@ function getAttachmentContentBytes(attachment: {
return null;
}
/** Whether the element takes up room on screen (not in a hidden tab or a display: none pane). */
function isShownOnScreen(el: HTMLElement): boolean {
if (typeof el.checkVisibility === 'function') return el.checkVisibility();
return el.getClientRects().length > 0;
}
/**
* Check if an HTML body string is effectively empty (just boilerplate/whitespace).
* Outlook often generates HTML bodies with Word CSS + &nbsp; but no real text.
@@ -3644,6 +3652,34 @@ export function EmailViewer({
openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined });
};
// What a printout of the message shows, for the Print action's window and
// for the page's print copy (Ctrl+P). Text fields are unescaped.
const messagePrintFields = (): MessagePrintFields | null => {
if (!email) return null;
const printSender = email.from?.[0];
const formatRecipient = (r: { name?: string | null; email: string }) =>
r.name ? `${r.name} <${r.email}>` : r.email;
// The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
// <style>. A printout has no iframe isolation, so re-sanitize with the
// stricter config that forbids <style> before injecting into the DOM.
// A plain-text body is escaped text with its own newlines: it prints its
// trailing quote opened instead of behind the collapsed toggle.
const plain = !effectiveEmailContent.isHtml;
return {
subject: email.subject || t('no_subject'),
sender: printSender?.name
? `${printSender.name} <${printSender.email}>`
: printSender?.email || t('unknown_sender'),
to: email.to?.map(formatRecipient).join(', ') || '',
cc: email.cc?.map(formatRecipient).join(', ') || '',
date: formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }),
bodyHtml: plain
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
: sanitizeEmailHtml(effectiveEmailContent.html),
plain,
};
};
// Print only the email content in a new window. A print asked for from
// the list's row menu brings its own window, opened in that click.
const handlePrint = (openedWindow?: Window) => {
@@ -3652,26 +3688,16 @@ export function EmailViewer({
openedWindow?.close();
return;
}
const fields = messagePrintFields();
if (!fields) return;
const printWatermark = watermark ? watermarkForPrint(watermark) : null;
const printSender = email.from?.[0];
const date = formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
const formatRecipient = (r: { name?: string | null; email: string }) =>
r.name ? `${escapeHtml(r.name)} &lt;${escapeHtml(r.email)}&gt;` : escapeHtml(r.email);
const toList = email.to?.map(formatRecipient).join(', ') || '';
const ccList = email.cc?.map(formatRecipient).join(', ') || '';
const subjectText = email.subject || t('no_subject');
const senderText = printSender?.name
? `${printSender.name} <${printSender.email}>`
: printSender?.email || t('unknown_sender');
// The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
// <style>. The print window has no iframe isolation, so re-sanitize with
// the stricter config that forbids <style> before injecting into the DOM.
// A plain-text body is escaped text with its own newlines: it prints its
// trailing quote opened instead of behind the collapsed toggle.
const plainBody = !effectiveEmailContent.isHtml;
const printableBody = plainBody
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
: sanitizeEmailHtml(effectiveEmailContent.html);
const date = fields.date;
const toList = escapeHtml(fields.to);
const ccList = escapeHtml(fields.cc);
const subjectText = fields.subject;
const senderText = fields.sender;
const plainBody = fields.plain;
const printableBody = fields.bodyHtml;
const printWindow = openedWindow ?? window.open('', '_blank');
if (!printWindow) return;
@@ -3738,6 +3764,20 @@ ${printWatermark ? printWatermark.html : ''}
if (!printRequest.win.closed) handlePrintRef.current(printRequest.win);
}, [printReady, printRequest]);
// Ctrl+P and the browser's Print menu print the message alone (app/print.css),
// not the app around it with its plugin panels and fixed heights. Only a
// reader on screen when printing starts: a Pro tab in the background keeps
// its message off the paper. Checked once, as the app is hidden while printing.
usePrintListener();
const printing = usePrinting();
const printShown = useMemo(
() => printing && !!viewerRootEl && isShownOnScreen(viewerRootEl),
[printing, viewerRootEl],
);
const printCopyFields = printShown && !devices.noPrint && !isLoading && !!email?.bodyValues
? messagePrintFields()
: null;
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
if (!email?.headers) return null;
@@ -4544,6 +4584,7 @@ ${printWatermark ? printWatermark.html : ''}
data-tour="email-viewer"
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{printCopyFields && <MessagePrint fields={printCopyFields} />}
{toolbarPosition === 'top' && liftToolbar ? topToolbar : toggleOnlyToolbar}
{/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { PrintRoot } from "@/components/print/print-root";
export interface MessagePrintFields {
subject: string;
sender: string;
to: string;
cc: string;
date: string;
/** Sanitized body HTML without <style>: a re-sanitized HTML body, or the escaped plain-text one. */
bodyHtml: string;
plain: boolean;
}
// The reader's image rule: cap only images that set no max-width of their own.
const BODY_CSS = `
:host { display: block; font-size: 10.5pt; line-height: 1.5; overflow-wrap: break-word; }
img { height: auto !important; }
img:not([style*="max-width"]) { max-width: 100% !important; }
img[style*="max-width"] { max-width: 100%; }
table { max-width: 100%; }
* { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
.plain { white-space: pre-wrap; word-break: break-word; }
.plain summary { display: none !important; }
`;
/**
* The open message as Ctrl+P prints it: header and body on white, with the
* app around it (toolbar, list, plugin panels) left off the paper. The same
* fields the reader's Print action puts in its window.
*
* The body goes into a shadow root: in the app's document it would get the
* app's CSS reset (no paragraph margins, no table borders) and its classes.
* It is filled in the commit, so it is there before the browser lays the
* page out for printing.
*/
export function MessagePrint({ fields }: { fields: MessagePrintFields }) {
const t = useTranslations("email_viewer");
const meta: Array<[string, string]> = [
[t("from"), fields.sender],
[t("to"), fields.to],
[t("cc"), fields.cc],
[t("date"), fields.date],
];
const { bodyHtml, plain } = fields;
const fillBody = useCallback((host: HTMLDivElement | null) => {
if (!host) return;
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
root.innerHTML = `<style>${BODY_CSS}</style><div dir="auto"${plain ? ' class="plain"' : ""}>${bodyHtml}</div>`;
}, [bodyHtml, plain]);
return (
<PrintRoot orientation="portrait" title={fields.subject}>
<article className="pr-message">
<header className="pr-header">
<h1 className="pr-title" dir="auto">{fields.subject}</h1>
<dl className="pr-message-meta">
{meta.filter(([, value]) => value).map(([label, value]) => (
<div key={label}>
<dt>{label}:</dt>
<dd dir="auto">{value}</dd>
</div>
))}
</dl>
</header>
<div className="pr-message-body" data-message-print-body="" ref={fillBody} />
</article>
</PrintRoot>
);
}