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