feat: print a clean copy of the page instead of the app shell

This commit is contained in:
Linus Rath
2026-10-28 21:08:20 +01:00
parent d9afc9b643
commit ba13866311
34 changed files with 979 additions and 0 deletions
+1
View File
@@ -18,6 +18,7 @@ import { withBasePath } from "@/lib/browser-navigation";
import { locales, defaultLocale } from "@/i18n/routing";
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
import "../globals.css";
import "../print.css";
// This layout renders <html> and sits ABOVE the [locale] segment, so
// next-intl's getLocale() returns the default locale here - emitting
+690
View File
@@ -0,0 +1,690 @@
/*
* Print copies (components/print/print-root.tsx). A page that can print
* renders its copy into <body> next to the app; in print media the copy
* replaces everything else there, so the nav rail, sidebars, toolbars, open
* menus, toasts and plugin UI never reach the paper, and nothing is clipped
* by the app's fixed heights or scroll containers.
*
* The copy is always black on white with thin grey rules, whatever theme the
* app is in, and uses logical properties so right-to-left pages mirror.
* Millimetre sizes match lib/calendar-print.ts.
*/
[data-print-root] {
display: none;
}
@media print {
/* The theme sets color-scheme inline on <html>; a dark one paints the
page margins dark. */
html:has(> body > [data-print-root]) {
color-scheme: light !important;
background: #fff !important;
}
body:has(> [data-print-root]) {
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
background: #fff !important;
color: #111 !important;
}
body:has(> [data-print-root]) > :not([data-print-root]) {
display: none !important;
}
[data-print-root] {
display: block;
}
}
.pr-root {
/* Landscape page area of the smaller of A4 and Letter, 12 mm margins. */
--pr-page-h: 184mm;
--pr-ink: #111;
--pr-muted: #555;
--pr-faint: #8a8a8a;
--pr-rule: #bdbdbd;
--pr-rule-light: #e2e2e2;
--pr-shade: #f4f4f4;
color: var(--pr-ink);
background: #fff;
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 10pt;
line-height: 1.35;
}
/* The copy declares its orientation with @page size, which browsers apply
whatever the dialog's paper says; an orientation media query, though,
follows the dialog. So the page height goes by the declared one. */
.pr-root[data-orientation="portrait"] {
--pr-page-h: 253mm;
}
.pr-root *,
.pr-root *::before,
.pr-root *::after {
border-color: var(--pr-rule);
box-sizing: border-box;
}
.pr-root a {
color: inherit;
text-decoration: none;
}
/* ---- Header on every calendar printout and list -------------------------- */
.pr-header {
margin-bottom: 3mm;
padding-bottom: 2mm;
border-bottom: 0.3mm solid var(--pr-rule);
break-after: avoid;
}
.pr-page > .pr-header {
height: 15mm;
flex: none;
overflow: hidden;
margin-bottom: 0;
}
.pr-title {
margin: 0;
font-size: 15pt;
font-weight: 600;
line-height: 1.25;
}
.pr-meta {
margin: 0.5mm 0 0;
color: var(--pr-muted);
font-size: 8.5pt;
}
.pr-page .pr-meta {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pr-meta-sep {
margin-inline: 2mm;
color: var(--pr-faint);
}
/* A view that fills exactly one sheet (month, week, day). */
.pr-page {
display: flex;
flex-direction: column;
height: var(--pr-page-h);
overflow: hidden;
}
/* ---- Event chips --------------------------------------------------------- */
/* The calendar colour as a bar at the start, a light tint of it behind dark
text: readable in colour and in greyscale. */
.pr-chip {
--chip: #3b82f6;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--pr-ink);
border-inline-start: 0.9mm solid var(--chip);
border-radius: 0.6mm;
background: color-mix(in srgb, var(--chip) 15%, #fff);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.pr-chip.is-timed {
background: #fff;
}
.pr-chip.is-inactive {
background: #fff;
color: var(--pr-muted);
}
.pr-chip.is-cancelled .pr-chip-title {
text-decoration: line-through;
}
.pr-chip.continues-before {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.pr-chip.continues-after {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.pr-chip-time {
flex: none;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.pr-chip-title {
font-weight: 500;
}
/* One-line chips: the title keeps its own direction (it is a <bdi>), so in
a right-to-left page an English title is still cut at its own end. */
.pr-lane-item > .pr-chip,
.pr-tg-event.is-compact > .pr-chip {
display: flex;
align-items: center;
gap: 0.8mm;
}
.pr-lane-item .pr-chip-title,
.pr-tg-event.is-compact .pr-chip-title {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Month --------------------------------------------------------------- */
.pr-month {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border: 0.25mm solid var(--pr-rule);
border-top: 0;
}
.pr-weekdays {
display: grid;
flex: none;
height: 6mm;
border-bottom: 0.25mm solid var(--pr-rule);
}
.pr-weekday {
align-self: end;
padding-bottom: 1mm;
text-align: center;
color: var(--pr-muted);
font-size: 7.5pt;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pr-month-week {
position: relative;
flex: 1 1 0;
min-height: 0;
display: grid;
border-top: 0.25mm solid var(--pr-rule);
}
.pr-month-week:first-of-type {
border-top: 0;
}
.pr-month-day {
min-width: 0;
padding: 0.6mm 1mm 0;
border-inline-start: 0.25mm solid var(--pr-rule-light);
}
.pr-month-day:first-child {
border-inline-start: 0;
}
.pr-month-day.is-outside {
background: var(--pr-shade);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.pr-day-num {
height: 4.4mm;
font-size: 8pt;
font-weight: 600;
line-height: 4.4mm;
}
.pr-month-day.is-outside .pr-day-num {
color: var(--pr-faint);
font-weight: 400;
}
.pr-day-month {
margin-inline-end: 1mm;
font-weight: 600;
}
.pr-lanes {
position: absolute;
inset-inline: 0;
top: 5mm;
bottom: 0;
pointer-events: none;
}
.pr-lane-item {
position: absolute;
height: 3.6mm;
padding-inline: 0.4mm;
font-size: 7pt;
line-height: 3.6mm;
}
.pr-lane-item > .pr-chip {
height: 100%;
padding-inline: 0.8mm;
}
.pr-more {
padding-inline: 1.2mm;
color: var(--pr-muted);
font-weight: 600;
white-space: nowrap;
}
/* ---- Week and day: the time grid ----------------------------------------- */
.pr-tg {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border: 0.25mm solid var(--pr-rule);
border-top: 0;
}
.pr-tg-row {
display: grid;
flex: none;
}
.pr-tg-dayhead {
height: 9mm;
min-width: 0;
padding: 1mm 0.8mm;
text-align: center;
border-inline-start: 0.25mm solid var(--pr-rule-light);
border-bottom: 0.25mm solid var(--pr-rule);
}
.pr-tg-weekday {
color: var(--pr-muted);
font-size: 7.5pt;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pr-tg-date {
font-size: 11pt;
font-weight: 600;
line-height: 1.2;
}
.pr-tg-gutter-head,
.pr-tg-gutter {
border-bottom: 0.25mm solid var(--pr-rule);
}
.pr-tg-allday-label {
padding: 0.6mm 1mm;
color: var(--pr-muted);
font-size: 6.5pt;
text-align: end;
border-bottom: 0.25mm solid var(--pr-rule);
}
.pr-tg-allday {
position: relative;
border-inline-start: 0.25mm solid var(--pr-rule-light);
border-bottom: 0.25mm solid var(--pr-rule);
}
.pr-tg-body {
position: relative;
flex: 1;
min-height: 0;
display: grid;
}
.pr-tg-hours {
position: relative;
}
.pr-tg-hour-label {
position: absolute;
inset-inline-end: 1mm;
transform: translateY(-50%);
color: var(--pr-muted);
font-size: 6.5pt;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
}
.pr-tg-hour-label.is-first {
transform: none;
margin-top: 0.6mm;
}
.pr-tg-col {
position: relative;
min-width: 0;
border-inline-start: 0.25mm solid var(--pr-rule-light);
}
.pr-tg-line {
position: absolute;
inset-inline: 0;
border-top: 0.2mm solid var(--pr-rule-light);
}
.pr-tg-line.is-half {
border-top-style: dotted;
}
.pr-tg-event {
position: absolute;
min-height: 3.2mm;
padding: 0.15mm 0.3mm;
}
.pr-tg-event > .pr-chip {
display: block;
height: 100%;
padding: 0.3mm 0.8mm;
font-size: 6.8pt;
line-height: 1.2;
white-space: normal;
}
.pr-tg-event .pr-chip-time {
display: block;
font-weight: 400;
color: var(--pr-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pr-tg-event .pr-chip-title {
display: block;
font-weight: 600;
overflow-wrap: anywhere;
}
/* Too short for two lines: start and title on one. */
.pr-tg-event.is-compact > .pr-chip {
padding-block: 0;
white-space: nowrap;
line-height: 2.9mm;
}
.pr-tg-event.is-compact .pr-chip-time {
color: var(--pr-ink);
font-weight: 600;
}
.pr-tg-event .pr-chip-where {
display: block;
color: var(--pr-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- Agenda, tasks, contact lists: flowing lists ------------------------- */
.pr-keep {
break-inside: avoid;
}
.pr-day-heading {
margin: 4mm 0 0;
padding-bottom: 1mm;
border-bottom: 0.3mm solid var(--pr-rule);
font-size: 10.5pt;
font-weight: 600;
break-after: avoid;
}
.pr-list-row {
display: grid;
gap: 3mm;
padding-block: 1.3mm;
border-bottom: 0.2mm solid var(--pr-rule-light);
break-inside: avoid;
}
.pr-agenda-row {
grid-template-columns: 30mm minmax(0, 1fr);
}
.pr-agenda-time {
color: var(--pr-muted);
font-variant-numeric: tabular-nums;
}
.pr-agenda-what {
padding-inline-start: 2mm;
border-inline-start: 1mm solid var(--chip, #3b82f6);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.pr-agenda-title {
font-weight: 600;
overflow-wrap: anywhere;
}
.pr-agenda-row.is-inactive .pr-agenda-title {
color: var(--pr-muted);
}
.pr-agenda-row.is-cancelled .pr-agenda-title {
text-decoration: line-through;
}
.pr-agenda-meta {
color: var(--pr-muted);
font-size: 8.5pt;
overflow-wrap: anywhere;
}
.pr-empty {
margin-top: 6mm;
color: var(--pr-muted);
}
.pr-task-row {
grid-template-columns: 4mm minmax(0, 1fr) 34mm 20mm;
align-items: start;
}
.pr-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.6mm;
height: 3.6mm;
margin-top: 0.5mm;
border: 0.3mm solid #333;
border-radius: 0.5mm;
font-size: 7pt;
line-height: 1;
}
.pr-task-title {
font-weight: 500;
overflow-wrap: anywhere;
}
.pr-task-row.is-done .pr-task-title {
color: var(--pr-muted);
text-decoration: line-through;
}
.pr-task-meta {
color: var(--pr-muted);
font-size: 8.5pt;
}
.pr-column-heads {
display: grid;
gap: 3mm;
padding-bottom: 1mm;
border-bottom: 0.3mm solid var(--pr-rule);
color: var(--pr-muted);
font-size: 7.5pt;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
break-after: avoid;
}
.pr-contact-row {
grid-template-columns: 27% 31% 18% minmax(0, 1fr);
padding-block: 0.8mm;
font-size: 8.5pt;
}
.pr-contact-row > *,
.pr-member-row > * {
min-width: 0;
overflow-wrap: anywhere;
}
.pr-contact-name {
font-weight: 600;
}
.pr-letter {
margin: 3.5mm 0 0;
padding-bottom: 0.8mm;
border-bottom: 0.3mm solid var(--pr-rule);
font-size: 10pt;
font-weight: 700;
break-after: avoid;
}
/* ---- One-item sheets (contact, group, directory entry, event) ------------ */
.pr-sheet {
font-size: 10.5pt;
}
.pr-sheet-head {
display: flex;
align-items: center;
gap: 6mm;
padding-bottom: 5mm;
border-bottom: 0.3mm solid var(--pr-rule);
}
.pr-sheet-head h1 {
margin: 0;
font-size: 18pt;
font-weight: 600;
line-height: 1.2;
overflow-wrap: anywhere;
}
.pr-sheet-sub {
margin: 1mm 0 0;
color: var(--pr-muted);
}
.pr-swatch {
flex: none;
width: 5mm;
height: 5mm;
border-radius: 1mm;
background: var(--chip, #3b82f6);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.pr-photo {
flex: none;
width: 25mm;
height: 25mm;
border-radius: 50%;
object-fit: cover;
}
.pr-photo.is-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #e5e7eb;
color: #4b5563;
font-size: 30pt;
font-weight: 600;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.pr-sheet section {
margin-top: 6mm;
break-inside: avoid;
}
.pr-sheet h2 {
margin: 0 0 2mm;
color: var(--pr-muted);
font-size: 8.5pt;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
break-after: avoid;
}
.pr-dl {
display: grid;
grid-template-columns: 38mm minmax(0, 1fr);
column-gap: 5mm;
row-gap: 1.6mm;
margin: 0;
}
.pr-dl > dt {
color: var(--pr-muted);
overflow-wrap: anywhere;
}
.pr-dl > dd {
margin: 0;
overflow-wrap: anywhere;
}
.pr-text {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* A long note or description may run over pages; its sections must not be
kept whole then. */
.pr-sheet section.pr-flow {
break-inside: auto;
}
.pr-member-row {
grid-template-columns: 34% 40% minmax(0, 1fr);
font-size: 9.5pt;
}
.pr-status {
color: var(--pr-muted);
white-space: nowrap;
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useLayoutEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { listenForPrinting, usePrintStore } from "@/stores/print-store";
export type PrintOrientation = "portrait" | "landscape";
/** Prints this page's copy on Ctrl+P while the page is mounted. */
export function usePrintListener(): void {
useEffect(() => listenForPrinting(), []);
}
/** True while the browser prints (or is about to): the page renders its print copy then. */
export function usePrinting(): boolean {
return usePrintStore((s) => s.printing);
}
interface PrintRootProps {
/** The paper orientation the copy is laid out for; the dialog may still change it. */
orientation: PrintOrientation;
/** The document title while printing: browsers use it as the PDF's file name and page header. */
title?: string;
children: ReactNode;
}
/**
* The print copy of a page. It sits next to the app in <body> and, in print
* media, replaces it (app/print.css), so no app chrome, open menu or toast
* reaches the paper. On screen it is never shown.
*/
export function PrintRoot({ orientation, title, children }: PrintRootProps) {
useLayoutEffect(() => {
if (!title) return;
const previous = document.title;
document.title = title;
return () => { document.title = previous; };
}, [title]);
return createPortal(
<div data-print-root="" data-orientation={orientation} className="pr-root">
<style>{`@page { size: ${orientation}; margin: 12mm; }`}</style>
{children}
</div>,
document.body,
);
}
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "إلغاء"
},
"common": {
"print": "طباعة",
"loading": "جارٍ التحميل...",
"error": "خطأ",
"success": "تم بنجاح",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Cancel·la"
},
"common": {
"print": "Imprimeix",
"loading": "Carregant...",
"error": "Error",
"success": "Correcte",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Zrušit"
},
"common": {
"print": "Tisk",
"loading": "Načítání...",
"error": "Chyba",
"success": "Úspěch",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Annuller"
},
"common": {
"print": "Udskriv",
"loading": "Indlæser...",
"error": "Fejl",
"success": "Succes",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Abbrechen"
},
"common": {
"print": "Drucken",
"loading": "Lädt...",
"error": "Fehler",
"success": "Erfolg",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Cancel"
},
"common": {
"print": "Print",
"loading": "Loading...",
"error": "Error",
"success": "Success",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Cancelar"
},
"common": {
"print": "Imprimir",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Tühista"
},
"common": {
"print": "Prindi",
"loading": "Laadimine...",
"error": "Viga",
"success": "Õnnestus",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "انصراف"
},
"common": {
"print": "چاپ",
"loading": "در حال بارگذاری...",
"error": "خطا",
"success": "موفقیت",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Annuler"
},
"common": {
"print": "Imprimer",
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès",
+1
View File
@@ -967,6 +967,7 @@
"cancel": "לְבַטֵל"
},
"common": {
"print": "הדפס",
"loading": "טוען...",
"error": "שגיאה",
"success": "הצלחה",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Mégse"
},
"common": {
"print": "Nyomtatás",
"loading": "Betöltés...",
"error": "Hiba",
"success": "Siker",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Annulla"
},
"common": {
"print": "Stampa",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "キャンセル"
},
"common": {
"print": "印刷",
"loading": "読み込み中...",
"error": "エラー",
"success": "成功",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "취소"
},
"common": {
"print": "인쇄",
"loading": "불러오는 중...",
"error": "오류",
"success": "성공",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Atcelt"
},
"common": {
"print": "Drukāt",
"loading": "Ielāde...",
"error": "Kļūda",
"success": "Gatavs",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Цуцлах"
},
"common": {
"print": "Хэвлэх",
"loading": "Ачааж байна...",
"error": "Алдаа",
"success": "Амжилт",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Avbryt"
},
"common": {
"print": "Skriv ut",
"loading": "Laster inn...",
"error": "Feil",
"success": "Vellykket",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Annuleren"
},
"common": {
"print": "Afdrukken",
"loading": "Laden...",
"error": "Fout",
"success": "Geslaagd",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Anuluj"
},
"common": {
"print": "Drukuj",
"loading": "Ładowanie...",
"error": "Błąd",
"success": "Sukces",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Cancelar"
},
"common": {
"print": "Imprimir",
"loading": "Carregando...",
"error": "Erro",
"success": "Sucesso",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Anulează"
},
"common": {
"print": "Imprimare",
"loading": "Se încarcă...",
"error": "Eroare",
"success": "Succes",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Отмена"
},
"common": {
"print": "Распечатать",
"loading": "Загрузка...",
"error": "Ошибка",
"success": "Успешно",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Zrušiť"
},
"common": {
"print": "Tlačiť",
"loading": "Načítavanie...",
"error": "Chyba",
"success": "Úspešne",
+1
View File
@@ -1,6 +1,7 @@
{
"meta_description": "เว็บเมลแบบมินิมอลที่ใช้โปรโตคอล JMAP",
"common": {
"print": "พิมพ์",
"loading": "กำลังโหลด...",
"error": "เกิดข้อผิดพลาด",
"success": "สำเร็จ",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "İptal"
},
"common": {
"print": "Yazdır",
"loading": "Yükleniyor...",
"error": "Hata",
"success": "Başarılı",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "Скасувати"
},
"common": {
"print": "Роздрукувати",
"loading": "Завантаження...",
"error": "Помилка",
"success": "Успіх",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "取消"
},
"common": {
"print": "列印",
"loading": "正在載入…",
"error": "錯誤",
"success": "成功",
+1
View File
@@ -1002,6 +1002,7 @@
"cancel": "取消"
},
"common": {
"print": "打印",
"loading": "正在加载…",
"error": "错误",
"success": "成功",
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '@testing-library/react';
import { listenForPrinting, requestPrint, usePrintStore } from '../print-store';
/** A page with a print copy, as the contacts and calendar pages render it. */
function Page() {
const printing = usePrintStore((s) => s.printing);
const target = usePrintStore((s) => s.target);
return printing ? <div data-print-root="">{target?.kind ?? 'default'}</div> : null;
}
const copy = () => document.querySelector('[data-print-root]')?.textContent ?? null;
describe('print store', () => {
let stop: (() => void) | null = null;
afterEach(() => {
stop?.();
stop = null;
usePrintStore.setState({ printing: false, target: null });
vi.restoreAllMocks();
});
it('puts the page\'s own copy in the DOM before beforeprint returns (Ctrl+P)', () => {
render(<Page />);
stop = listenForPrinting();
let during: string | null = null;
// The browser lays the page out for printing right after the handlers ran.
window.addEventListener('beforeprint', () => { during = copy(); }, { once: true });
window.dispatchEvent(new Event('beforeprint'));
expect(during).toBe('default');
window.dispatchEvent(new Event('afterprint'));
expect(usePrintStore.getState().printing).toBe(false);
});
it('renders the asked-for copy before it opens the print dialog (Print button)', async () => {
render(<Page />);
stop = listenForPrinting();
let atDialog: string | null = null;
vi.spyOn(window, 'print').mockImplementation(() => { atDialog = copy(); });
await requestPrint({ kind: 'contact-list' });
expect(window.print).toHaveBeenCalledOnce();
expect(atDialog).toBe('contact-list');
// The dialog's own beforeprint does not replace the asked-for copy.
window.dispatchEvent(new Event('beforeprint'));
expect(copy()).toBe('contact-list');
});
it('stops listening once no page with a copy is mounted', () => {
render(<Page />);
listenForPrinting()();
window.dispatchEvent(new Event('beforeprint'));
expect(usePrintStore.getState().printing).toBe(false);
});
it('puts scroll offsets back after printing, which display: none dropped', async () => {
render(<Page />);
stop = listenForPrinting();
const pane = document.createElement('div');
document.body.appendChild(pane);
// jsdom has no layout: give the pane an offset of its own.
Object.defineProperty(pane, 'scrollTop', { value: 420, writable: true });
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0; });
window.dispatchEvent(new Event('beforeprint'));
pane.scrollTop = 0;
window.dispatchEvent(new Event('afterprint'));
expect(pane.scrollTop).toBe(420);
pane.remove();
});
});
+137
View File
@@ -0,0 +1,137 @@
import { create } from "zustand";
import { flushSync } from "react-dom";
import type { CalendarEvent, ContactCard } from "@/lib/jmap/types";
import type { DirectoryEntry } from "@/stores/contact-store";
/**
* What a Print action asked for. Ctrl+P asks for nothing in particular: the
* page then prints what it shows (its default).
*/
export type PrintTarget =
| { kind: "contact-list" }
| { kind: "contact"; contact: ContactCard; photoUri?: string }
| { kind: "contact-group"; group: ContactCard }
| { kind: "directory-list" }
| { kind: "directory-entry"; entry: DirectoryEntry }
| { kind: "calendar-view" }
| { kind: "calendar-event"; event: CalendarEvent };
interface PrintStore {
/** True from the moment printing starts until the browser is done with it. */
printing: boolean;
/** The explicit request, or null while printing what the page shows. */
target: PrintTarget | null;
}
export const usePrintStore = create<PrintStore>(() => ({
printing: false,
target: null,
}));
/**
* Puts the print copy into the page right away: the browser lays the page
* out for printing as soon as the handler that started it returns, so the
* copy must be in the DOM by then.
*/
function startPrinting(target: PrintTarget | null): void {
saveScrollOffsets();
flushSync(() => usePrintStore.setState({ printing: true, target }));
}
function stopPrinting(): void {
if (!usePrintStore.getState().printing) return;
usePrintStore.setState({ printing: false, target: null });
restoreScrollOffsets();
}
/*
* While the copy prints, the app is display: none, which drops the scroll
* offsets of its panes, and the views that measure themselves (the week
* strip) recompute from a zero size. Their offsets are put back once the
* page is laid out for the screen again.
*/
let savedOffsets: Array<[Element, number, number]> = [];
function saveScrollOffsets(): void {
savedOffsets = [];
document.querySelectorAll("body *").forEach((el) => {
if (el.scrollTop !== 0 || el.scrollLeft !== 0) savedOffsets.push([el, el.scrollTop, el.scrollLeft]);
});
}
function restoreScrollOffsets(): void {
const offsets = savedOffsets;
savedOffsets = [];
if (offsets.length === 0) return;
// After the frame in which resize observers and their effects ran.
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const [el, top, left] of offsets) {
if (!el.isConnected) continue;
el.scrollTop = top;
el.scrollLeft = left;
}
}));
}
const IMAGE_WAIT_MS = 3000;
/** Waits for the print copy's images (a contact photo) so they are not printed blank. */
async function waitForPrintImages(): Promise<void> {
const images = Array.from(document.querySelectorAll<HTMLImageElement>("[data-print-root] img"));
if (images.length === 0) return;
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
Promise.all(images.map((img) => img.decode().catch(() => undefined))),
new Promise<void>((resolve) => { timer = setTimeout(resolve, IMAGE_WAIT_MS); }),
]);
clearTimeout(timer);
}
/**
* A Print button: renders the copy of `target` and opens the browser's print
* dialog, which gives the same page Ctrl+P would for that view.
*/
export async function requestPrint(target: PrintTarget): Promise<void> {
if (usePrintStore.getState().printing) return;
startPrinting(target);
await waitForPrintImages();
if (!usePrintStore.getState().printing) return;
window.print();
// Browsers whose print() returns before the dialog closes reset on afterprint.
}
let listeners = 0;
let printQuery: MediaQueryList | null = null;
const handleBeforePrint = () => {
if (!usePrintStore.getState().printing) startPrinting(null);
};
const handleAfterPrint = () => stopPrinting();
// Safari has fired beforeprint late or not at all; the print media query
// changes in every browser.
const handlePrintQuery = (e: MediaQueryListEvent) => {
if (e.matches) handleBeforePrint();
else handleAfterPrint();
};
/**
* Listens for the browser's own printing (Ctrl+P, the menu) while a page with
* a print copy is mounted. Returns the cleanup.
*/
export function listenForPrinting(): () => void {
if (typeof window === "undefined") return () => {};
if (listeners++ === 0) {
window.addEventListener("beforeprint", handleBeforePrint);
window.addEventListener("afterprint", handleAfterPrint);
printQuery = typeof window.matchMedia === "function" ? window.matchMedia("print") : null;
printQuery?.addEventListener?.("change", handlePrintQuery);
}
return () => {
if (--listeners > 0) return;
window.removeEventListener("beforeprint", handleBeforePrint);
window.removeEventListener("afterprint", handleAfterPrint);
printQuery?.removeEventListener?.("change", handlePrintQuery);
printQuery = null;
stopPrinting();
};
}