From ba13866311bda276e87795c388743f2764fa9860 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:08:01 +0100 Subject: [PATCH] feat: print a clean copy of the page instead of the app shell --- app/(main)/layout.tsx | 1 + app/print.css | 690 ++++++++++++++++++++++++++ components/print/print-root.tsx | 47 ++ locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + stores/__tests__/print-store.test.tsx | 75 +++ stores/print-store.ts | 137 +++++ 34 files changed, 979 insertions(+) create mode 100644 app/print.css create mode 100644 components/print/print-root.tsx create mode 100644 stores/__tests__/print-store.test.tsx create mode 100644 stores/print-store.ts diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index e42a2509e..33134aff3 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -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 and sits ABOVE the [locale] segment, so // next-intl's getLocale() returns the default locale here - emitting diff --git a/app/print.css b/app/print.css new file mode 100644 index 000000000..34cdc2450 --- /dev/null +++ b/app/print.css @@ -0,0 +1,690 @@ +/* + * Print copies (components/print/print-root.tsx). A page that can print + * renders its copy into 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 ; 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 ), 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; +} diff --git a/components/print/print-root.tsx b/components/print/print-root.tsx new file mode 100644 index 000000000..5490fce55 --- /dev/null +++ b/components/print/print-root.tsx @@ -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 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( +
+ + {children} +
, + document.body, + ); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index a29e7908d..ab330ec27 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1002,6 +1002,7 @@ "cancel": "إلغاء" }, "common": { + "print": "طباعة", "loading": "جارٍ التحميل...", "error": "خطأ", "success": "تم بنجاح", diff --git a/locales/ca/common.json b/locales/ca/common.json index cf7a73317..14674ce81 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1002,6 +1002,7 @@ "cancel": "Cancel·la" }, "common": { + "print": "Imprimeix", "loading": "Carregant...", "error": "Error", "success": "Correcte", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5b06f2464..68ccd3cc3 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1002,6 +1002,7 @@ "cancel": "Zrušit" }, "common": { + "print": "Tisk", "loading": "Načítání...", "error": "Chyba", "success": "Úspěch", diff --git a/locales/da/common.json b/locales/da/common.json index 1621b2ea9..e68be171f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1002,6 +1002,7 @@ "cancel": "Annuller" }, "common": { + "print": "Udskriv", "loading": "Indlæser...", "error": "Fejl", "success": "Succes", diff --git a/locales/de/common.json b/locales/de/common.json index 65639ed57..a54a07d15 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1002,6 +1002,7 @@ "cancel": "Abbrechen" }, "common": { + "print": "Drucken", "loading": "Lädt...", "error": "Fehler", "success": "Erfolg", diff --git a/locales/en/common.json b/locales/en/common.json index cb3c6cada..56b613f06 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1002,6 +1002,7 @@ "cancel": "Cancel" }, "common": { + "print": "Print", "loading": "Loading...", "error": "Error", "success": "Success", diff --git a/locales/es/common.json b/locales/es/common.json index 9510a0bff..dc172a4e9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1002,6 +1002,7 @@ "cancel": "Cancelar" }, "common": { + "print": "Imprimir", "loading": "Cargando...", "error": "Error", "success": "Éxito", diff --git a/locales/et/common.json b/locales/et/common.json index 7583bcee6..a4871bd56 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1002,6 +1002,7 @@ "cancel": "Tühista" }, "common": { + "print": "Prindi", "loading": "Laadimine...", "error": "Viga", "success": "Õnnestus", diff --git a/locales/fa/common.json b/locales/fa/common.json index c1897196a..c9c633f13 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1002,6 +1002,7 @@ "cancel": "انصراف" }, "common": { + "print": "چاپ", "loading": "در حال بارگذاری...", "error": "خطا", "success": "موفقیت", diff --git a/locales/fr/common.json b/locales/fr/common.json index bfd168d29..3d9973ce9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1002,6 +1002,7 @@ "cancel": "Annuler" }, "common": { + "print": "Imprimer", "loading": "Chargement...", "error": "Erreur", "success": "Succès", diff --git a/locales/he/common.json b/locales/he/common.json index 885bd196c..f54d2a573 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -967,6 +967,7 @@ "cancel": "לְבַטֵל" }, "common": { + "print": "הדפס", "loading": "טוען...", "error": "שגיאה", "success": "הצלחה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 50f0878e6..a63c5e0ea 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1002,6 +1002,7 @@ "cancel": "Mégse" }, "common": { + "print": "Nyomtatás", "loading": "Betöltés...", "error": "Hiba", "success": "Siker", diff --git a/locales/it/common.json b/locales/it/common.json index ad9be542c..053513df8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1002,6 +1002,7 @@ "cancel": "Annulla" }, "common": { + "print": "Stampa", "loading": "Caricamento...", "error": "Errore", "success": "Successo", diff --git a/locales/ja/common.json b/locales/ja/common.json index 1e2286886..1f5fa4183 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1002,6 +1002,7 @@ "cancel": "キャンセル" }, "common": { + "print": "印刷", "loading": "読み込み中...", "error": "エラー", "success": "成功", diff --git a/locales/ko/common.json b/locales/ko/common.json index 661c9c8cd..4590cdf71 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1002,6 +1002,7 @@ "cancel": "취소" }, "common": { + "print": "인쇄", "loading": "불러오는 중...", "error": "오류", "success": "성공", diff --git a/locales/lv/common.json b/locales/lv/common.json index aecc82a38..60f71b8fc 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1002,6 +1002,7 @@ "cancel": "Atcelt" }, "common": { + "print": "Drukāt", "loading": "Ielāde...", "error": "Kļūda", "success": "Gatavs", diff --git a/locales/mn/common.json b/locales/mn/common.json index 0cfe2e9ae..dcdbd2bfa 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1002,6 +1002,7 @@ "cancel": "Цуцлах" }, "common": { + "print": "Хэвлэх", "loading": "Ачааж байна...", "error": "Алдаа", "success": "Амжилт", diff --git a/locales/nb/common.json b/locales/nb/common.json index a0a27a3a1..7f818915e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1002,6 +1002,7 @@ "cancel": "Avbryt" }, "common": { + "print": "Skriv ut", "loading": "Laster inn...", "error": "Feil", "success": "Vellykket", diff --git a/locales/nl/common.json b/locales/nl/common.json index ab9f4c522..4f693ae3f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1002,6 +1002,7 @@ "cancel": "Annuleren" }, "common": { + "print": "Afdrukken", "loading": "Laden...", "error": "Fout", "success": "Geslaagd", diff --git a/locales/pl/common.json b/locales/pl/common.json index ae908f057..c1c40f53b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1002,6 +1002,7 @@ "cancel": "Anuluj" }, "common": { + "print": "Drukuj", "loading": "Ładowanie...", "error": "Błąd", "success": "Sukces", diff --git a/locales/pt/common.json b/locales/pt/common.json index 87ce6069c..555c1064c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1002,6 +1002,7 @@ "cancel": "Cancelar" }, "common": { + "print": "Imprimir", "loading": "Carregando...", "error": "Erro", "success": "Sucesso", diff --git a/locales/ro/common.json b/locales/ro/common.json index 7bc3b07c0..ef28c93dd 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1002,6 +1002,7 @@ "cancel": "Anulează" }, "common": { + "print": "Imprimare", "loading": "Se încarcă...", "error": "Eroare", "success": "Succes", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9f1069091..4d01748a4 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1002,6 +1002,7 @@ "cancel": "Отмена" }, "common": { + "print": "Распечатать", "loading": "Загрузка...", "error": "Ошибка", "success": "Успешно", diff --git a/locales/sk/common.json b/locales/sk/common.json index 6205ed76d..11b051863 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1002,6 +1002,7 @@ "cancel": "Zrušiť" }, "common": { + "print": "Tlačiť", "loading": "Načítavanie...", "error": "Chyba", "success": "Úspešne", diff --git a/locales/th/common.json b/locales/th/common.json index 397083769..45d984041 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1,6 +1,7 @@ { "meta_description": "เว็บเมลแบบมินิมอลที่ใช้โปรโตคอล JMAP", "common": { + "print": "พิมพ์", "loading": "กำลังโหลด...", "error": "เกิดข้อผิดพลาด", "success": "สำเร็จ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 1e0898c33..749e94003 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1002,6 +1002,7 @@ "cancel": "İptal" }, "common": { + "print": "Yazdır", "loading": "Yükleniyor...", "error": "Hata", "success": "Başarılı", diff --git a/locales/uk/common.json b/locales/uk/common.json index cb868b1aa..a9f72a3ee 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1002,6 +1002,7 @@ "cancel": "Скасувати" }, "common": { + "print": "Роздрукувати", "loading": "Завантаження...", "error": "Помилка", "success": "Успіх", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index aa66308f9..87087253e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1002,6 +1002,7 @@ "cancel": "取消" }, "common": { + "print": "列印", "loading": "正在載入…", "error": "錯誤", "success": "成功", diff --git a/locales/zh/common.json b/locales/zh/common.json index 889b2fd90..4a59db206 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1002,6 +1002,7 @@ "cancel": "取消" }, "common": { + "print": "打印", "loading": "正在加载…", "error": "错误", "success": "成功", diff --git a/stores/__tests__/print-store.test.tsx b/stores/__tests__/print-store.test.tsx new file mode 100644 index 000000000..9296b1f7d --- /dev/null +++ b/stores/__tests__/print-store.test.tsx @@ -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 ?
{target?.kind ?? 'default'}
: 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/stores/print-store.ts b/stores/print-store.ts new file mode 100644 index 000000000..518beabff --- /dev/null +++ b/stores/print-store.ts @@ -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(() => ({ + 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 { + const images = Array.from(document.querySelectorAll("[data-print-root] img")); + if (images.length === 0) return; + let timer: ReturnType | undefined; + await Promise.race([ + Promise.all(images.map((img) => img.decode().catch(() => undefined))), + new Promise((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 { + 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(); + }; +}