mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: print a clean copy of the page instead of the app shell
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "إلغاء"
|
||||
},
|
||||
"common": {
|
||||
"print": "طباعة",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"error": "خطأ",
|
||||
"success": "تم بنجاح",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Cancel·la"
|
||||
},
|
||||
"common": {
|
||||
"print": "Imprimeix",
|
||||
"loading": "Carregant...",
|
||||
"error": "Error",
|
||||
"success": "Correcte",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Zrušit"
|
||||
},
|
||||
"common": {
|
||||
"print": "Tisk",
|
||||
"loading": "Načítání...",
|
||||
"error": "Chyba",
|
||||
"success": "Úspěch",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Annuller"
|
||||
},
|
||||
"common": {
|
||||
"print": "Udskriv",
|
||||
"loading": "Indlæser...",
|
||||
"error": "Fejl",
|
||||
"success": "Succes",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"common": {
|
||||
"print": "Drucken",
|
||||
"loading": "Lädt...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"common": {
|
||||
"print": "Print",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"common": {
|
||||
"print": "Imprimir",
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Tühista"
|
||||
},
|
||||
"common": {
|
||||
"print": "Prindi",
|
||||
"loading": "Laadimine...",
|
||||
"error": "Viga",
|
||||
"success": "Õnnestus",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "انصراف"
|
||||
},
|
||||
"common": {
|
||||
"print": "چاپ",
|
||||
"loading": "در حال بارگذاری...",
|
||||
"error": "خطا",
|
||||
"success": "موفقیت",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"common": {
|
||||
"print": "Imprimer",
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
|
||||
@@ -967,6 +967,7 @@
|
||||
"cancel": "לְבַטֵל"
|
||||
},
|
||||
"common": {
|
||||
"print": "הדפס",
|
||||
"loading": "טוען...",
|
||||
"error": "שגיאה",
|
||||
"success": "הצלחה",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Mégse"
|
||||
},
|
||||
"common": {
|
||||
"print": "Nyomtatás",
|
||||
"loading": "Betöltés...",
|
||||
"error": "Hiba",
|
||||
"success": "Siker",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"common": {
|
||||
"print": "Stampa",
|
||||
"loading": "Caricamento...",
|
||||
"error": "Errore",
|
||||
"success": "Successo",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"common": {
|
||||
"print": "印刷",
|
||||
"loading": "読み込み中...",
|
||||
"error": "エラー",
|
||||
"success": "成功",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "취소"
|
||||
},
|
||||
"common": {
|
||||
"print": "인쇄",
|
||||
"loading": "불러오는 중...",
|
||||
"error": "오류",
|
||||
"success": "성공",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Atcelt"
|
||||
},
|
||||
"common": {
|
||||
"print": "Drukāt",
|
||||
"loading": "Ielāde...",
|
||||
"error": "Kļūda",
|
||||
"success": "Gatavs",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Цуцлах"
|
||||
},
|
||||
"common": {
|
||||
"print": "Хэвлэх",
|
||||
"loading": "Ачааж байна...",
|
||||
"error": "Алдаа",
|
||||
"success": "Амжилт",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"common": {
|
||||
"print": "Skriv ut",
|
||||
"loading": "Laster inn...",
|
||||
"error": "Feil",
|
||||
"success": "Vellykket",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"common": {
|
||||
"print": "Afdrukken",
|
||||
"loading": "Laden...",
|
||||
"error": "Fout",
|
||||
"success": "Geslaagd",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"common": {
|
||||
"print": "Drukuj",
|
||||
"loading": "Ładowanie...",
|
||||
"error": "Błąd",
|
||||
"success": "Sukces",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"common": {
|
||||
"print": "Imprimir",
|
||||
"loading": "Carregando...",
|
||||
"error": "Erro",
|
||||
"success": "Sucesso",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Anulează"
|
||||
},
|
||||
"common": {
|
||||
"print": "Imprimare",
|
||||
"loading": "Se încarcă...",
|
||||
"error": "Eroare",
|
||||
"success": "Succes",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"common": {
|
||||
"print": "Распечатать",
|
||||
"loading": "Загрузка...",
|
||||
"error": "Ошибка",
|
||||
"success": "Успешно",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Zrušiť"
|
||||
},
|
||||
"common": {
|
||||
"print": "Tlačiť",
|
||||
"loading": "Načítavanie...",
|
||||
"error": "Chyba",
|
||||
"success": "Úspešne",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"meta_description": "เว็บเมลแบบมินิมอลที่ใช้โปรโตคอล JMAP",
|
||||
"common": {
|
||||
"print": "พิมพ์",
|
||||
"loading": "กำลังโหลด...",
|
||||
"error": "เกิดข้อผิดพลาด",
|
||||
"success": "สำเร็จ",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "İptal"
|
||||
},
|
||||
"common": {
|
||||
"print": "Yazdır",
|
||||
"loading": "Yükleniyor...",
|
||||
"error": "Hata",
|
||||
"success": "Başarılı",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "Скасувати"
|
||||
},
|
||||
"common": {
|
||||
"print": "Роздрукувати",
|
||||
"loading": "Завантаження...",
|
||||
"error": "Помилка",
|
||||
"success": "Успіх",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "取消"
|
||||
},
|
||||
"common": {
|
||||
"print": "列印",
|
||||
"loading": "正在載入…",
|
||||
"error": "錯誤",
|
||||
"success": "成功",
|
||||
|
||||
@@ -1002,6 +1002,7 @@
|
||||
"cancel": "取消"
|
||||
},
|
||||
"common": {
|
||||
"print": "打印",
|
||||
"loading": "正在加载…",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user